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

🔧 fix: update navigation href resolution to include localized paths

🆕 feat: add new FeatureIcon component for feature boxes

🎨 style: improve styling for prose elements in richtext blocks

🛠️ refactor: streamline medialib image loading and caching logic

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

🔄 chore: synchronize i18n initialization and locale management

📝 docs: update video tour descriptions to reflect recent changes
This commit is contained in:
2026-05-12 13:55:32 +00:00
parent 8fb26fdeba
commit e84b87ed16
41 changed files with 1523 additions and 338 deletions
+1 -1
View File
@@ -391,7 +391,7 @@ function getRenderedElement(
export { 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. **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 | | Type | Component | Purpose |
| -------------- | ------------------------- | ----------------------------------------- | | -------------- | ------------------------- | ----------------------------------------- |
| `hero` | `HeroBlock.svelte` | Full-width hero with image, headline, CTA | | `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 | | `richtext` | `RichtextBlock.svelte` | Rich text with optional image |
| `accordion` | `AccordionBlock.svelte` | Expandable FAQ/accordion items | | `accordion` | `AccordionBlock.svelte` | Expandable FAQ/accordion items |
| `contact-form` | `ContactFormBlock.svelte` | Contact form | | `contact-form` | `ContactFormBlock.svelte` | Contact form |
+9 -11
View File
@@ -38,25 +38,23 @@ git remote rename origin template
Three placeholders must be replaced in the correct files: Three placeholders must be replaced in the correct files:
| Placeholder | Files | Format | Example | | Placeholder | Files | Format | Example |
| -------------------- | -------------------------------------- | --------------------------------------------------------- | ------------ | | -------------------- | ---------------------------------------------- | --------------------------------------------------------- | ------------ |
| `__PROJECT_NAME__` | `.env` | kebab-case (used for URLs, Docker containers, subdomains) | `my-project` | | `__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` | | `__TIBI_NAMESPACE__` | `.env`, `api/config.yml`, `frontend/.htaccess` | 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` |
```sh ```sh
PROJECT=my-project # kebab-case PROJECT=my-project # kebab-case
NAMESPACE=my_project # snake_case NAMESPACE=my_project # snake_case
sed -i "s/__PROJECT_NAME__/$PROJECT/g" .env sed -i "s/__PROJECT_NAME__/$PROJECT/g" .env
sed -i "s/__TIBI_NAMESPACE__/$NAMESPACE/g" .env sed -i "s/__TIBI_NAMESPACE__/$NAMESPACE/g" .env api/config.yml frontend/.htaccess
sed -i "s/__NAMESPACE__/$NAMESPACE/g" api/config.yml frontend/.htaccess
``` ```
**Verify each replacement:** **Verify each replacement:**
```sh ```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) # 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. - **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 `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 ## Step 3 — Page title
@@ -146,7 +144,7 @@ For a real project, remove or replace the demo files:
| File/Folder | Content | | 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/content.json` | Demo mock data for content |
| `frontend/mocking/navigation.json` | Demo mock data for navigation | | `frontend/mocking/navigation.json` | Demo mock data for navigation |
| `api/collections/content.yml` | Content collection config | | `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 ```sh
yarn build # Frontend bundle for modern browsers yarn build # Frontend bundle for modern browsers
yarn build:server # SSR bundle (for tibi-server goja hooks) 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) yarn validate # TypeScript + Svelte checks (must show 0 errors and 0 warnings)
``` ```
+1 -1
View File
@@ -22,4 +22,4 @@ STAGING_URL=https://dev-__PROJECT_NAME__.staging.testversion.online
CODING_URL=https://__PROJECT_NAME__.code.testversion.online CODING_URL=https://__PROJECT_NAME__.code.testversion.online
#START_SCRIPT=:ssr #START_SCRIPT=:ssr
MOCK=1 #MOCK=1
-6
View File
@@ -39,12 +39,6 @@ jobs:
run: | run: |
yarn build yarn build
- name: build admin
env:
FORCE_COLOR: "true"
run: |
yarn build:admin
- name: build ssr - name: build ssr
env: env:
FORCE_COLOR: "true" FORCE_COLOR: "true"
+6 -6
View File
@@ -5,12 +5,12 @@
"editor.formatOnPaste": true, "editor.formatOnPaste": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"yaml.schemas": { "yaml.schemas": {
"./../../cms/tibi-types/schemas/api-config/config.json": "api/config.y*ml", "./../../cms/tibi-types/schemas/config/project.schema.json": "api/config.y*ml",
"./../../cms/tibi-types/schemas/api-config/collection.json": "api/collections/*.y*ml", "./../../cms/tibi-types/schemas/config/collection.schema.json": "api/collections/*.y*ml",
"./../../cms/tibi-types/schemas/api-config/field.json": "api/collections/fields/*.y*ml", "./../../cms/tibi-types/schemas/config/field.schema.json": "api/collections/fields/*.y*ml",
"./../../cms/tibi-types/schemas/api-config/fieldArray.json": "api/collections/fieldLists/*.y*ml", "./../../cms/tibi-types/schemas/config/field-list.schema.json": "api/collections/fieldLists/*.y*ml",
"./../../cms/tibi-types/schemas/api-config/job.json": "api/jobs/*.y*ml", "./../../cms/tibi-types/schemas/config/job.schema.json": "api/jobs/*.y*ml",
"./../../cms/tibi-types/schemas/api-config/assets.json": "api/assets/*.y*ml" "./../../cms/tibi-types/schemas/config/asset.schema.json": "api/assets/*.y*ml"
}, },
"yaml.customTags": ["!include scalar"], "yaml.customTags": ["!include scalar"],
"filewatcher.commands": [ "filewatcher.commands": [
+1 -1
View File
@@ -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_COMPOSE) --profile tibi-dev --profile tibi --profile chisel logs --tail=$*
docker-pull: ## pull docker images docker-pull: ## pull docker images
$(DOCKER_COMPOSE) --profile tibi-dev --profile tibi --profile chisel pull $(DOCKER_COMPOSE) --profile tibi --profile chisel pull
docker-%: docker-%:
$(DOCKER_COMPOSE) $* $(DOCKER_COMPOSE) $*
+2 -6
View File
@@ -10,7 +10,7 @@ Starter Kit für SPAs(s) `;)` mit Svelte und TibiCMS inkl. SSR
[ ] Repository geklont und Remotes konfiguriert [ ] Repository geklont und Remotes konfiguriert
[ ] __PROJECT_NAME__ in .env ersetzt (kebab-case) [ ] __PROJECT_NAME__ in .env ersetzt (kebab-case)
[ ] __TIBI_NAMESPACE__ in .env ersetzt (snake_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) [ ] Keine verbleibenden __*__-Platzhalter (mit grep prüfen)
[ ] App.svelte hat <svelte:head> mit <title> [ ] App.svelte hat <svelte:head> mit <title>
[ ] ADMIN_TOKEN in api/config.yml.env gesetzt [ ] 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 - `<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` - SSR-Bundle wird mit `yarn build:server` erstellt und landet in `api/hooks/lib/app.server.js`
**Weiteres Build-Target:** Der normale Frontend-Build `yarn build` erzeugt sowohl das Frontend-Bundle als auch das Admin-Modul `admin.mjs`.
```sh
yarn build:admin # Admin-Module
```
+332 -48
View File
@@ -6,24 +6,28 @@ name: content
meta: meta:
label: { de: "Inhalte", en: "Content" } label: { de: "Inhalte", en: "Content" }
muiIcon: article muiIcon: article
rowIdentTpl: { twig: "{{ name }}" } group: content
preview:
views: label: name
- type: simpleList secondary: path
mediaQuery: "(max-width: 600px)" tertiary: lang
primaryText: name badge: type
secondaryText: lang image: _pagebuilderThumbnail
tertiaryText: path pagebuilder:
- type: table screenshot:
columns: - field: blocks
- name fileField: _pagebuilderThumbnail
- source: lang i18n:
filter: true entry:
- source: type languageField: lang
filter: true groupField: translationKey
- source: path sidebar:
- source: active - group: publishing
filter: true label: { de: "Veröffentlichung", en: "Publishing" }
- group: settings
label: { de: "Einstellungen", en: "Settings" }
- group: seo
label: { de: "SEO", en: "SEO" }
permissions: permissions:
public: public:
@@ -41,18 +45,22 @@ fields:
type: boolean type: boolean
meta: meta:
label: { de: "Aktiv", en: "Active" } label: { de: "Aktiv", en: "Active" }
position: sidebar:publishing
- name: type - name: type
type: string type: string
meta: meta:
label: { de: "Typ", en: "Type" } label: { de: "Typ", en: "Type" }
position: sidebar:settings
- name: lang - name: lang
type: string type: string
meta: meta:
label: { de: "Sprache", en: "Language" } label: { de: "Sprache", en: "Language" }
position: sidebar:settings
- name: translationKey - name: translationKey
type: string type: string
meta: meta:
label: { de: "Übersetzungsschlüssel", en: "Translation Key" } label: { de: "Übersetzungsschlüssel", en: "Translation Key" }
position: sidebar:settings
- name: name - name: name
type: string type: string
meta: meta:
@@ -68,104 +76,380 @@ fields:
subFields: subFields:
- name: path - name: path
type: string type: string
- name: thumbnail
type: string
meta:
label: { de: "Vorschaubild", en: "Thumbnail" }
- name: teaserText - name: teaserText
type: string type: string
meta: meta:
label: { de: "Teasertext", en: "Teaser Text" } label: { de: "Teasertext", en: "Teaser Text" }
- name: _pagebuilderThumbnail
type: file
meta:
hide: true
- name: blocks - name: blocks
type: object[] type: object[]
meta: meta:
label: { de: "Inhaltsblöcke", en: "Content Blocks" } 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: subFields:
- name: hide - name: hide
type: boolean type: boolean
meta:
label: { de: "Block ausblenden", en: "Hide Block" }
dependsOn:
eval: $parent.type != ''
containerProps:
layout:
size: col-4
- name: type - name: type
type: string 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 - name: headline
type: string 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 - name: headlineH1
type: boolean type: boolean
meta:
label: { de: "Als H1 rendern", en: "Render as H1" }
dependsOn:
eval: $parent.type == 'hero'
containerProps:
layout:
size: col-4
- name: subline - name: subline
type: string type: string
meta:
label: { de: "Unterzeile", en: "Subline" }
dependsOn:
eval: $parent.type == 'hero'
inputProps:
multiline: true
rows: 3
- name: tagline - name: tagline
type: string 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 - name: anchorId
type: string 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 - name: containerWidth
type: string type: string
- name: background meta:
type: object label: { de: "Containerbreite", en: "Container Width" }
subFields: widget: select
- name: color dependsOn:
type: string eval: $parent.type == 'hero'
- name: image containerProps:
type: string 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 - name: padding
type: object 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: subFields:
- name: top - name: top
type: string 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 - name: bottom
type: string 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 - name: callToAction
type: object type: object
meta:
label: { de: "Call-to-Action", en: "Call to Action" }
dependsOn:
eval: $parent.type == 'hero'
drillDown: false
subFields: subFields:
- name: buttonText - name: buttonText
type: string type: string
meta:
label: { de: "Button-Text", en: "Button Text" }
containerProps:
layout:
size: col-4
- name: buttonLink - name: buttonLink
type: string type: string
meta:
label: { de: "Button-Link", en: "Button Link" }
containerProps:
layout:
size: col-5
- name: buttonTarget - name: buttonTarget
type: string 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 - name: heroImage
type: object type: object
meta:
label: { de: "Hero-Bild", en: "Hero Image" }
dependsOn:
eval: $parent.type == 'hero'
drillDown: false
subFields: subFields:
- name: image - name: image
type: string type: string
meta:
label: { de: "Bild", en: "Image" }
widget: foreignMedia
foreign:
collection: medialib
id: _id
subNavigation: 0
- name: text - name: text
type: string 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 - name: imagePosition
type: string type: string
- name: imageRounded meta:
type: string 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 - name: image
type: string 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 - name: accordionItems
type: object[] type: object[]
meta:
label: { de: "Akkordeon-Elemente", en: "Accordion Items" }
dependsOn:
eval: $parent.type == 'accordion'
drillDown: true
subFields: subFields:
- name: question - name: question
type: string type: string
meta:
label: { de: "Frage", en: "Question" }
containerProps:
layout:
size: col-8
- name: answer - name: answer
type: string type: string
meta:
label: { de: "Antwort", en: "Answer" }
widget: richtext
inputProps:
rows: 6
containerProps:
layout:
breakAfter: true
- name: open - name: open
type: boolean type: boolean
- name: imageGallery meta:
type: object label: { de: "Initial geöffnet", en: "Initially Open" }
subFields: containerProps:
- name: images layout:
type: object[] size: col-4
subFields:
- name: image
type: string
- name: caption
type: string
- name: showCaption
type: boolean
- name: showImageCaption
type: boolean
- name: imageCaption
type: string
- name: meta - name: meta
type: object type: object
meta: meta:
widget: containerLessObject
label: { de: "SEO", en: "SEO" } label: { de: "SEO", en: "SEO" }
position: sidebar:seo
subFields: subFields:
- name: title - name: title
type: string type: string
meta:
label: { de: "Meta-Titel", en: "Meta Title" }
- name: description - name: description
type: string type: string
meta:
label: { de: "Meta-Beschreibung", en: "Meta Description" }
inputProps:
multiline: true
rows: 3
- name: keywords - name: keywords
type: string type: string[]
meta:
label: { de: "Meta-Schlüsselwörter", en: "Meta Keywords" }
+120
View File
@@ -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
+63 -14
View File
@@ -6,19 +6,45 @@ name: navigation
meta: meta:
label: { de: "Navigation", en: "Navigation" } label: { de: "Navigation", en: "Navigation" }
muiIcon: menu muiIcon: menu
rowIdentTpl: { twig: "{{ type }} ({{ language }})" } group: structure
viewHint:
views: navigation:
- type: simpleList nodesField: elements
mediaQuery: "(max-width: 600px)" preview:
primaryText: type label: name
secondaryText: language secondary:
- type: table eval: "$this.external && $this.externalUrl ? $this.externalUrl : ($this._lookup?.page ? $this._lookup.page.name + ' (' + $this._lookup.page.path + ')' : '')"
columns: select: [external, externalUrl, page]
- source: type declaredTrees:
filter: true - label: { de: "Header DE", en: "Header DE" }
- source: language singleton:
filter: true 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: permissions:
public: public:
@@ -36,15 +62,30 @@ fields:
type: string type: string
meta: meta:
label: { de: "Sprache", en: "Language" } 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 - name: type
type: string type: string
meta: meta:
label: { de: "Typ", en: "Type" } label: { de: "Typ", en: "Type" }
helperText: { de: "header oder footer", en: "header or footer" } 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 - name: elements
type: object[] type: object[]
meta: meta:
label: { de: "Elemente", en: "Elements" } label: { de: "Elemente", en: "Elements" }
preview: name
subFields: subFields:
- name: name - name: name
type: string type: string
@@ -53,7 +94,11 @@ fields:
- name: page - name: page
type: string type: string
meta: meta:
label: { de: "Seite (Content-ID)", en: "Page (Content ID)" } label: { de: "Seite", en: "Page" }
widget: foreignKey
foreign:
collection: content
id: id
- name: external - name: external
type: boolean type: boolean
meta: meta:
@@ -66,3 +111,7 @@ fields:
type: string type: string
meta: meta:
label: { de: "Anker", en: "Anchor" } label: { de: "Anker", en: "Anchor" }
- name: elements
type: object[]
meta:
label: { de: "Unterpunkte", en: "Child Items" }
+2 -16
View File
@@ -6,22 +6,8 @@ name: ssr
meta: meta:
label: { de: "SSR Dummy", en: "ssr dummy" } label: { de: "SSR Dummy", en: "ssr dummy" }
muiIcon: server muiIcon: server
rowIdentTpl: { twig: "{{ id }}" } group: system
hide: true
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
permissions: permissions:
public: public:
+23 -6
View File
@@ -1,15 +1,32 @@
namespace: __NAMESPACE__ namespace: __TIBI_NAMESPACE__
meta: meta:
imageUrl: imageUrl:
eval: "$projectBase + '_/assets/img/admin-pic.jpg'" eval: "$projectBase + '_/assets/img/admin-pic.svg'"
injectIntoHead: i18n:
# inject font faces (not possible in shadow dom for preview) defaultLanguage: de
eval: | languages:
"<link rel='stylesheet' href='" + $projectBase + "_/assets/fonts/fonts.css?t=" + $project?.updateTime + "'>" - 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: collections:
- !include collections/content.yml - !include collections/content.yml
- !include collections/medialib.yml
- !include collections/navigation.yml - !include collections/navigation.yml
- !include collections/ssr.yml - !include collections/ssr.yml
+6 -3
View File
@@ -96,13 +96,16 @@ const esbuildSvelte = sveltePlugin({
const options = { const options = {
logLevel: "info", logLevel: "info",
color: true, color: true,
entryPoints: ["./frontend/src/index.ts"], entryPoints: ["./frontend/src/index.ts", "./frontend/src/admin.ts"],
outfile: distDir + "/index.mjs", outdir: distDir,
entryNames: "[name]",
chunkNames: "chunks/[name]-[hash]",
outExtension: { ".js": ".mjs" },
metafile: true, metafile: true,
format: "esm", format: "esm",
minify: process.argv[2] == "build", minify: process.argv[2] == "build",
bundle: true, bundle: true,
splitting: false, splitting: true,
define: { define: {
__MOCK__: process.env.MOCK === "1" ? "true" : "false", __MOCK__: process.env.MOCK === "1" ? "true" : "false",
}, },
+2 -2
View File
@@ -10,7 +10,7 @@ SetEnv MATOMO no
RewriteEngine On RewriteEngine On
RewriteBase / 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 # Set the Host header for requests to sentry
RequestHeader set Host sentry.basehosts.de env=proxy-sentry RequestHeader set Host sentry.basehosts.de env=proxy-sentry
@@ -36,7 +36,7 @@ SetEnv MATOMO no
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d 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] # RewriteRule (.*) /spa.html [QSA,L]
</ifModule> </ifModule>
View File
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
<title id="title">Tibi Starter admin preview</title>
<desc id="desc">Abstract admin preview illustration for the Tibi starter project.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f2d45" />
<stop offset="50%" stop-color="#1f5f7a" />
<stop offset="100%" stop-color="#d9e7f1" />
</linearGradient>
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95" />
<stop offset="100%" stop-color="#dfeaf2" stop-opacity="0.92" />
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg)" />
<circle cx="1320" cy="140" r="160" fill="#ffffff" fill-opacity="0.14" />
<circle cx="260" cy="760" r="220" fill="#ffffff" fill-opacity="0.09" />
<rect x="180" y="150" width="1240" height="600" rx="36" fill="url(#card)" />
<rect x="240" y="210" width="280" height="480" rx="24" fill="#10324a" fill-opacity="0.92" />
<rect x="570" y="230" width="780" height="74" rx="18" fill="#1f5f7a" fill-opacity="0.18" />
<rect x="570" y="340" width="360" height="250" rx="24" fill="#1f5f7a" fill-opacity="0.16" />
<rect x="970" y="340" width="380" height="110" rx="24" fill="#1f5f7a" fill-opacity="0.13" />
<rect x="970" y="480" width="380" height="110" rx="24" fill="#1f5f7a" fill-opacity="0.13" />
<path d="M300 300h160M300 360h160M300 420h160M300 480h110" stroke="#e7f0f6" stroke-width="22" stroke-linecap="round" />
<path d="M635 470l92-92 78 78 142-142 180 180" fill="none" stroke="#0f2d45" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="748" cy="330" r="34" fill="#0f2d45" />
<text x="570" y="275" fill="#10324a" font-family="Georgia, serif" font-size="42" font-weight="700">Tibi Admin Nova Starter</text>
<text x="570" y="655" fill="#10324a" font-family="Arial, sans-serif" font-size="34">Lean starter base with content, media, i18n, and SSR.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 752 B

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

After

Width:  |  Height:  |  Size: 183 B

+8
View File
@@ -0,0 +1,8 @@
{
"name": "Tibi Svelte Starter",
"short_name": "Tibi Starter",
"icons": [],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+129 -33
View File
@@ -1,7 +1,8 @@
[ [
{ {
"id": "home-de", "_id": {
"_id": "home-de", "$oid": "6821c0a10000000000000001"
},
"active": true, "active": true,
"type": "page", "type": "page",
"lang": "de", "lang": "de",
@@ -21,19 +22,50 @@
"buttonTarget": "" "buttonTarget": ""
}, },
"heroImage": { "heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80" "image": "6821c0a10000000000000201"
} }
}, },
{ {
"type": "features", "type": "features",
"headline": "Was dieses Template kann", "headline": "Was dieses Template kann",
"tagline": "Features", "tagline": "Highlights",
"anchorId": "features", "anchorId": "features",
"padding": { "padding": {
"top": "lg", "top": "lg",
"bottom": "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", "type": "richtext",
@@ -44,9 +76,9 @@
"top": "lg", "top": "lg",
"bottom": "sm" "bottom": "sm"
}, },
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80", "image": "6821c0a10000000000000202",
"imagePosition": "right", "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", "type": "accordion",
@@ -81,12 +113,19 @@
"meta": { "meta": {
"title": "Tibi Svelte Starter — Modernes CMS-Template", "title": "Tibi Svelte Starter — Modernes CMS-Template",
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n und Playwright-Tests — das perfekte Starterkit.", "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": {
"_id": "home-en", "$oid": "6821c0a10000000000000002"
},
"active": true, "active": true,
"type": "page", "type": "page",
"lang": "en", "lang": "en",
@@ -106,7 +145,7 @@
"buttonTarget": "" "buttonTarget": ""
}, },
"heroImage": { "heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80" "image": "6821c0a10000000000000201"
} }
}, },
{ {
@@ -118,7 +157,38 @@
"top": "lg", "top": "lg",
"bottom": "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", "type": "richtext",
@@ -129,9 +199,9 @@
"top": "lg", "top": "lg",
"bottom": "sm" "bottom": "sm"
}, },
"externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80", "image": "6821c0a10000000000000202",
"imagePosition": "right", "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", "type": "accordion",
@@ -166,12 +236,19 @@
"meta": { "meta": {
"title": "Tibi Svelte Starter — Modern CMS Template", "title": "Tibi Svelte Starter — Modern CMS Template",
"description": "Svelte 5, Tailwind CSS 4, SSR, i18n and Playwright tests — the perfect starter kit.", "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": {
"_id": "about-de", "$oid": "6821c0a10000000000000003"
},
"active": true, "active": true,
"type": "page", "type": "page",
"lang": "de", "lang": "de",
@@ -186,7 +263,7 @@
"subline": "Gebaut für Teams, die schnell professionelle Webprojekte umsetzen wollen.", "subline": "Gebaut für Teams, die schnell professionelle Webprojekte umsetzen wollen.",
"containerWidth": "full", "containerWidth": "full",
"heroImage": { "heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80" "image": "6821c0a10000000000000203"
} }
}, },
{ {
@@ -205,7 +282,7 @@
"top": "md", "top": "md",
"bottom": "lg" "bottom": "lg"
}, },
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80", "image": "6821c0a10000000000000204",
"imagePosition": "left", "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>" "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": { "meta": {
"title": "Über das Template — Tibi Svelte Starter", "title": "Über das Template — Tibi Svelte Starter",
"description": "Architektur und Tech-Stack des Tibi Svelte Starter Templates.", "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": {
"_id": "about-en", "$oid": "6821c0a10000000000000004"
},
"active": true, "active": true,
"type": "page", "type": "page",
"lang": "en", "lang": "en",
@@ -233,7 +316,7 @@
"subline": "Built for teams who want to ship professional web projects fast.", "subline": "Built for teams who want to ship professional web projects fast.",
"containerWidth": "full", "containerWidth": "full",
"heroImage": { "heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80" "image": "6821c0a10000000000000203"
} }
}, },
{ {
@@ -252,7 +335,7 @@
"top": "md", "top": "md",
"bottom": "lg" "bottom": "lg"
}, },
"externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80", "image": "6821c0a10000000000000204",
"imagePosition": "left", "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>" "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": { "meta": {
"title": "About the Template — Tibi Svelte Starter", "title": "About the Template — Tibi Svelte Starter",
"description": "Architecture and tech stack of the Tibi Svelte Starter Template.", "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": {
"_id": "contact-de", "$oid": "6821c0a10000000000000005"
},
"active": true, "active": true,
"type": "page", "type": "page",
"lang": "de", "lang": "de",
@@ -280,7 +369,7 @@
"subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!", "subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!",
"containerWidth": "full", "containerWidth": "full",
"heroImage": { "heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80" "image": "6821c0a10000000000000205"
} }
}, },
{ {
@@ -295,12 +384,16 @@
"meta": { "meta": {
"title": "Kontakt — Tibi Svelte Starter", "title": "Kontakt — Tibi Svelte Starter",
"description": "Nimm Kontakt mit uns auf.", "description": "Nimm Kontakt mit uns auf.",
"keywords": "kontakt, anfrage" "keywords": [
"kontakt",
"anfrage"
]
} }
}, },
{ {
"id": "contact-en", "_id": {
"_id": "contact-en", "$oid": "6821c0a10000000000000006"
},
"active": true, "active": true,
"type": "page", "type": "page",
"lang": "en", "lang": "en",
@@ -315,7 +408,7 @@
"subline": "Questions, feedback or project inquiries? Get in touch!", "subline": "Questions, feedback or project inquiries? Get in touch!",
"containerWidth": "full", "containerWidth": "full",
"heroImage": { "heroImage": {
"externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80" "image": "6821c0a10000000000000205"
} }
}, },
{ {
@@ -330,7 +423,10 @@
"meta": { "meta": {
"title": "Contact — Tibi Svelte Starter", "title": "Contact — Tibi Svelte Starter",
"description": "Get in touch with us.", "description": "Get in touch with us.",
"keywords": "contact, inquiry" "keywords": [
"contact",
"inquiry"
]
} }
} }
] ]
+97
View File
@@ -0,0 +1,97 @@
[
{
"_id": {
"$oid": "6821c0a10000000000000201"
},
"file": {
"src": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Homepage Hero",
"alt": {
"de": "Abstrakte Ansicht einer vernetzten digitalen Weltkarte",
"en": "Abstract view of a connected digital world map"
},
"description": "Hero image for the homepage demo.",
"tags": [
"hero",
"homepage"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000202"
},
"file": {
"src": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Workflow",
"alt": {
"de": "Laptop mit geoeffnetem Code-Editor auf einem Schreibtisch",
"en": "Laptop with an open code editor on a desk"
},
"description": "Workflow image for the richtext demo block.",
"tags": [
"workflow",
"development"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000203"
},
"file": {
"src": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "About Team",
"alt": {
"de": "Team sitzt gemeinsam an einem grossen Tisch und arbeitet zusammen",
"en": "Team working together around a large table"
},
"description": "Hero image for the about page demo.",
"tags": [
"about",
"team"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000204"
},
"file": {
"src": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Technology Stack",
"alt": {
"de": "Nahaufnahme eines Bildschirms mit Code und farbiger Syntaxhervorhebung",
"en": "Close-up of a screen with code and colorful syntax highlighting"
},
"description": "Technology stack image for the about page demo.",
"tags": [
"about",
"code"
]
},
{
"_id": {
"$oid": "6821c0a10000000000000205"
},
"file": {
"src": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80&auto=format&fit=crop",
"type": "image/jpeg"
},
"title": "Contact Hero",
"alt": {
"de": "Arbeitsplatz mit Laptop, Telefon und Notizbuch fuer Kontaktanfragen",
"en": "Workspace with laptop, phone, and notebook for contact inquiries"
},
"description": "Hero image for the contact page demo.",
"tags": [
"contact",
"hero"
]
}
]
+70 -22
View File
@@ -1,48 +1,96 @@
[ [
{ {
"id": "header-de", "_id": {
"_id": "header-de", "$oid": "6821c0a10000000000000101"
},
"language": "de", "language": "de",
"type": "header", "type": "header",
"elements": [ "elements": [
{ "name": "Startseite", "page": "/" }, {
{ "name": "Über uns", "page": "/ueber-uns" }, "name": "Startseite",
{ "name": "Kontakt", "page": "/kontakt" } "page": "6821c0a10000000000000001"
},
{
"name": "Über uns",
"page": "6821c0a10000000000000003"
},
{
"name": "Kontakt",
"page": "6821c0a10000000000000005"
}
] ]
}, },
{ {
"id": "header-en", "_id": {
"_id": "header-en", "$oid": "6821c0a10000000000000102"
},
"language": "en", "language": "en",
"type": "header", "type": "header",
"elements": [ "elements": [
{ "name": "Home", "page": "/" }, {
{ "name": "About", "page": "/about" }, "name": "Home",
{ "name": "Contact", "page": "/contact" } "page": "6821c0a10000000000000002"
},
{
"name": "About",
"page": "6821c0a10000000000000004"
},
{
"name": "Contact",
"page": "6821c0a10000000000000006"
}
] ]
}, },
{ {
"id": "footer-de", "_id": {
"_id": "footer-de", "$oid": "6821c0a10000000000000103"
},
"language": "de", "language": "de",
"type": "footer", "type": "footer",
"elements": [ "elements": [
{ "name": "Startseite", "page": "/" }, {
{ "name": "Über uns", "page": "/ueber-uns" }, "name": "Startseite",
{ "name": "Kontakt", "page": "/kontakt" }, "page": "6821c0a10000000000000001"
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" } },
{
"name": "Über uns",
"page": "6821c0a10000000000000003"
},
{
"name": "Kontakt",
"page": "6821c0a10000000000000005"
},
{
"name": "GitHub",
"external": true,
"externalUrl": "https://github.com"
}
] ]
}, },
{ {
"id": "footer-en", "_id": {
"_id": "footer-en", "$oid": "6821c0a10000000000000104"
},
"language": "en", "language": "en",
"type": "footer", "type": "footer",
"elements": [ "elements": [
{ "name": "Home", "page": "/" }, {
{ "name": "About", "page": "/about" }, "name": "Home",
{ "name": "Contact", "page": "/contact" }, "page": "6821c0a10000000000000002"
{ "name": "GitHub", "external": true, "externalUrl": "https://github.com" } },
{
"name": "About",
"page": "6821c0a10000000000000004"
},
{
"name": "Contact",
"page": "6821c0a10000000000000006"
},
{
"name": "GitHub",
"external": true,
"externalUrl": "https://github.com"
}
] ]
} }
] ]
+102 -11
View File
@@ -22,6 +22,9 @@
stripLanguageFromPath, stripLanguageFromPath,
} from "./lib/i18n" } 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() let { url = "" }: { url?: string } = $props()
initScrollRestoration() 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 // metrics
let oldPath = $state("") let oldPath = $state("")
$effect(() => { $effect(() => {
@@ -74,6 +119,16 @@
let contentEntry = $state<ContentEntry | null>(null) let contentEntry = $state<ContentEntry | null>(null)
let headerNav = $state<NavigationEntry | null>(null) let headerNav = $state<NavigationEntry | null>(null)
let footerNav = $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 loading = $state(true)
let notFound = $state(false) let notFound = $state(false)
@@ -103,18 +158,42 @@
try { try {
// Load navigation // Load navigation
const [headerEntries, footerEntries] = await Promise.all([ const [headerEntries, footerEntries] = await Promise.all([
getCachedEntries<"navigation">("navigation", { type: "header", language: lang }), getCachedEntries<"navigation">(
getCachedEntries<"navigation">("navigation", { type: "footer", language: lang }), "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 headerNav = headerEntries[0] || null
footerNav = footerEntries[0] || null footerNav = footerEntries[0] || null
// Load content for current path // Load content for current path
const contentEntries = await getCachedEntries<"content">("content", { const contentEntries = await getCachedEntries<"content">(
lang, "content",
path: routePath, {
active: true, lang,
}) path: routePath,
active: true,
},
"sort",
undefined,
undefined,
undefined,
{ lookup: CONTENT_MEDIA_LOOKUP }
)
if (contentEntries.length > 0) { if (contentEntries.length > 0) {
contentEntry = contentEntries[0] contentEntry = contentEntries[0]
@@ -157,6 +236,9 @@
{#if contentEntry?.meta?.description} {#if contentEntry?.meta?.description}
<meta name="description" content={contentEntry.meta.description} /> <meta name="description" content={contentEntry.meta.description} />
{/if} {/if}
{#if contentEntry?.meta?.keywords?.length}
<meta name="keywords" content={contentEntry.meta.keywords.join(", ")} />
{/if}
</svelte:head> </svelte:head>
<LoadingBar /> <LoadingBar />
@@ -168,6 +250,15 @@
? 'bg-white/80 backdrop-blur-lg shadow-sm' ? 'bg-white/80 backdrop-blur-lg shadow-sm'
: 'bg-transparent'}" : '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"> <div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<!-- Logo --> <!-- Logo -->
<a <a
@@ -184,7 +275,7 @@
{#if headerNav?.elements} {#if headerNav?.elements}
{#each headerNav.elements as item} {#each headerNav.elements as item}
<a <a
href={localizedPath(item.page || "/")} href={resolveNavigationHref(item)}
class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled class="text-sm font-medium transition-colors duration-300 hover:text-brand-500 {scrolled
? 'text-gray-700' ? 'text-gray-700'
: 'text-white/90'}" : 'text-white/90'}"
@@ -248,7 +339,7 @@
{#if headerNav?.elements} {#if headerNav?.elements}
{#each headerNav.elements as item} {#each headerNav.elements as item}
<a <a
href={localizedPath(item.page || "/")} href={resolveNavigationHref(item)}
class="block text-gray-700 text-lg font-medium hover:text-brand-600 py-2" class="block text-gray-700 text-lg font-medium hover:text-brand-600 py-2"
> >
{item.name} {item.name}
@@ -273,7 +364,7 @@
</header> </header>
<!-- ── Main Content ──────────────────────────────────────────── --> <!-- ── Main Content ──────────────────────────────────────────── -->
<main> <main id="main-content" tabindex="-1">
{#if loading} {#if loading}
<div class="min-h-screen flex items-center justify-center"> <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> <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> </a>
{:else} {:else}
<a <a
href={localizedPath(item.page || "/")} href={resolveNavigationHref(item)}
class="text-sm hover:text-white transition-colors" class="text-sm hover:text-white transition-colors"
> >
{item.name} {item.name}
+96 -2
View File
@@ -1,4 +1,40 @@
import type { SvelteComponent } from "svelte" import { mount, unmount, type Component, type SvelteComponent } from "svelte"
import BlockRenderer from "./blocks/BlockRenderer.svelte"
const previewCssUrl = new URL("./index.css", import.meta.url).toString()
type BlockRenderContext = {
namespace?: string
apiBase?: string
projectBase?: string
}
type BlockHandle = {
update(row: Record<string, any>, context?: BlockRenderContext): void
destroy(): void
}
type BlockDefinition = {
render(container: HTMLElement | ShadowRoot, row: Record<string, any>, context?: BlockRenderContext): BlockHandle
css?: string[]
previewStyles?: Record<string, string>
label?: string
icon?: string
color?: string
}
type BlockPresentation = {
label: string
icon: string
color: string
}
function getAdminPreviewProps(props?: { [key: string]: any }) {
return {
isAdminPreview: true,
...(props || {}),
}
}
function getRenderedElement( function getRenderedElement(
component: typeof SvelteComponent, component: typeof SvelteComponent,
@@ -33,13 +69,71 @@ function getRenderedElement(
new component({ new component({
target: target, target: target,
props: options?.props, props: getAdminPreviewProps(options?.props),
}) })
return el 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 { export {
getAdminPreviewProps,
getRenderedElement, getRenderedElement,
blockRegistry,
// pass also required svelte components here // pass also required svelte components here
} }
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
let { icon = "lightning", className = "" }: { icon?: ContentFeatureBoxEntry["icon"]; className?: string } = $props()
</script>
{#if icon === "palette"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.04-.23-.29-.38-.63-.38-1.01 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.17-4.49-9-10-9z"
></path>
<circle cx="7.5" cy="11.5" r="1.5"></circle>
<circle cx="10.5" cy="7.5" r="1.5"></circle>
<circle cx="14.5" cy="7.5" r="1.5"></circle>
<circle cx="17.5" cy="11.5" r="1.5"></circle>
</svg>
{:else if icon === "database"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 22v-5"></path>
<path d="M9 8V2"></path>
<path d="M15 8V2"></path>
<path d="M18 8v5a6 6 0 0 1-12 0V8h12z"></path>
</svg>
{:else if icon === "globe"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20"></path>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
{:else if icon === "monitor"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2"></rect>
<path d="M8 21h8"></path>
<path d="M12 17v4"></path>
</svg>
{:else if icon === "flask"}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 2h6"></path>
<path
d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.578A1 1 0 0 0 5.598 22h12.804a1 1 0 0 0 .878-1.422l-5.069-10.155A2 2 0 0 1 14 9.527V2"
></path>
</svg>
{:else}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
{/if}
+16 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { reveal } from "../lib/actions/reveal" import { reveal } from "../lib/actions/reveal"
import FeatureIcon from "./FeatureIcon.svelte"
let { block }: { block: ContentBlockEntry } = $props() let { block }: { block: ContentBlockEntry } = $props()
@@ -41,9 +42,21 @@
</div> </div>
{/if} {/if}
{#if block.text} {#if block.featureBoxes?.length}
<div class="prose max-w-none" use:reveal={{ delay: 200 }}> <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3" use:reveal={{ delay: 200 }}>
{@html block.text} {#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> </div>
{/if} {/if}
</div> </div>
+13 -4
View File
@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import { reveal } from "../lib/actions/reveal" import { reveal } from "../lib/actions/reveal"
import { spaLink } from "../lib/navigation" import { spaLink } from "../lib/navigation"
import MedialibImage from "../widgets/MedialibImage.svelte"
let { block }: { block: ContentBlockEntry } = $props() 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("#")) const isAnchorLink = $derived(block.callToAction?.buttonLink?.startsWith("#"))
</script> </script>
@@ -17,9 +23,12 @@
<!-- Background image --> <!-- Background image -->
{#if hasImage} {#if hasImage}
<div class="absolute inset-0 z-0"> <div class="absolute inset-0 z-0">
{#if block.heroImage?.externalUrl} <MedialibImage
<img src={block.heroImage.externalUrl} alt="" class="w-full h-full object-cover" loading="lazy" /> id={block.heroImage?.image || resolvedHeroImage?.id || resolvedHeroImage?._id || ""}
{/if} 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 class="absolute inset-0 bg-linear-to-b from-brand-950/80 via-brand-900/70 to-brand-950/90"></div>
</div> </div>
{:else} {:else}
+13 -8
View File
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { reveal } from "../lib/actions/reveal" import { reveal } from "../lib/actions/reveal"
import MedialibImage from "../widgets/MedialibImage.svelte"
let { block }: { block: ContentBlockEntry } = $props() let { block }: { block: ContentBlockEntry } = $props()
const resolvedImage = $derived(block._lookup?.image || null)
const paddingTop = $derived( const paddingTop = $derived(
block.padding?.top === "lg" block.padding?.top === "lg"
? "pt-20" ? "pt-20"
@@ -22,10 +25,10 @@
: "pb-4" : "pb-4"
) )
const hasImage = $derived(block.externalImageUrl || block.image) const hasImage = $derived(block.image || resolvedImage)
const imageOnRight = $derived(block.imagePosition === "right") const imageOnRight = $derived(block.imagePosition === "right")
const imageOnLeft = $derived(block.imagePosition === "left") const imageOnLeft = $derived(block.imagePosition === "left")
const showImage = $derived(hasImage && (imageOnRight || imageOnLeft)) const showImage = $derived(hasImage && !!resolvedImage?.file?.src && (imageOnRight || imageOnLeft))
</script> </script>
<section data-block="richtext" class="richtext-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}> <section data-block="richtext" class="richtext-section {paddingTop} {paddingBottom}" id={block.anchorId || undefined}>
@@ -54,12 +57,14 @@
</div> </div>
<div class:order-1={imageOnLeft} class="relative"> <div class:order-1={imageOnLeft} class="relative">
<div class="rounded-2xl overflow-hidden shadow-xl shadow-brand-900/10"> <div class="rounded-2xl overflow-hidden shadow-xl shadow-brand-900/10">
<img {#if block.image || resolvedImage}
src={block.externalImageUrl || ""} <MedialibImage
alt={block.headline || ""} id={block.image || resolvedImage?.id || resolvedImage?._id || ""}
class="w-full h-auto object-cover aspect-4/3" entry={resolvedImage}
loading="lazy" noPlaceholder
/> style="width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;"
/>
{/if}
</div> </div>
<!-- Decorative gradient behind image --> <!-- Decorative gradient behind image -->
<div <div
+4
View File
@@ -60,6 +60,10 @@
} }
/* ── Prose styling for CMS richtext blocks ───────────────────────── */ /* ── Prose styling for CMS richtext blocks ───────────────────────── */
.prose {
color: var(--color-gray-900, #111827);
}
.prose h2 { .prose h2 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
+5
View File
@@ -20,6 +20,11 @@ export interface RevealOptions {
} }
export function reveal(node: HTMLElement, options: 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 if (typeof IntersectionObserver === "undefined") return
const { delay = 0, threshold = 0.15, once = true } = options const { delay = 0, threshold = 0.15, once = true } = options
+44 -18
View File
@@ -16,6 +16,47 @@ export { locale, isLoading, addMessages } from "svelte-i18n"
register("de", () => import("./locales/de.json")) register("de", () => import("./locales/de.json"))
register("en", () => import("./locales/en.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. * 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> { export async function setupI18n(url?: string): Promise<void> {
const initialLocale = getInitialLocale(url) const initialLocale = getInitialLocale(url)
init({ ensureI18nInitialized(initialLocale)
fallbackLocale: DEFAULT_LANGUAGE, ensureLocaleSync()
initialLocale,
})
selectedLanguage.set(initialLocale) selectedLanguage.set(initialLocale)
locale.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)
}
})
await waitLocale() await waitLocale()
} }
+1
View File
@@ -50,6 +50,7 @@
}, },
"welcome": "Willkommen", "welcome": "Willkommen",
"language": "Sprache", "language": "Sprache",
"skipToMainContent": "Zum Hauptinhalt springen",
"scrollToTop": "Nach oben", "scrollToTop": "Nach oben",
"loading": "Laden…" "loading": "Laden…"
} }
+1
View File
@@ -50,6 +50,7 @@
}, },
"welcome": "Welcome", "welcome": "Welcome",
"language": "Language", "language": "Language",
"skipToMainContent": "Skip to main content",
"scrollToTop": "Scroll to top", "scrollToTop": "Scroll to top",
"loading": "Loading…" "loading": "Loading…"
} }
+123 -5
View File
@@ -14,11 +14,50 @@
// Add new collections here as needed. // Add new collections here as needed.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import contentData from "../../mocking/content.json" import contentData from "../../mocking/content.json"
import medialibData from "../../mocking/medialib.json"
import navigationData from "../../mocking/navigation.json" import navigationData from "../../mocking/navigation.json"
type EJsonObjectId = {
$oid: string
}
const mockRegistry: Record<string, Record<string, unknown>[]> = { const mockRegistry: Record<string, Record<string, unknown>[]> = {
content: contentData as Record<string, unknown>[], content: normalizeMockCollection(contentData as Record<string, unknown>[]),
navigation: navigationData 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 --- // --- Single-item retrieval ---
if (itemId) { if (itemId) {
const item = sourceData.find((e) => e.id === itemId || e._id === itemId) const item = sourceData.find((e) => e.id === itemId || e._id === itemId)
const resultItem = item ? applyLookups(cloneEntry(item), options) : null
return { return {
data: item ?? null, data: resultItem,
count: item ? 1 : 0, count: resultItem ? 1 : 0,
buildTime: null, buildTime: null,
} }
} }
@@ -79,6 +119,8 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
results = results.slice(0, options.limit) results = results.slice(0, options.limit)
} }
results = results.map((entry) => applyLookups(cloneEntry(entry), options))
// Projection // Projection
if (options?.projection) { if (options?.projection) {
results = applyProjection(results, options.projection) results = applyProjection(results, options.projection)
@@ -184,6 +226,81 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
}, obj) }, 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 // Sort
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -244,7 +361,8 @@ function applyProjection(data: Record<string, unknown>[], projectionStr: string)
if (field in entry) result[field] = entry[field] if (field in entry) result[field] = entry[field]
} }
// Always include id/_id // 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 if (entry._id !== undefined) result._id = entry._id
return result return result
} }
+23 -77
View File
@@ -1,47 +1,10 @@
<script lang="ts"> <script lang="ts">
import { getDBEntries, getDBEntry } from "../lib/api"
import { apiBaseURL } from "../config" import { apiBaseURL } from "../config"
import { apiBaseOverride } from "../lib/store" import { currentLanguage, DEFAULT_LANGUAGE } from "../lib/i18n"
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]
}
interface Props { interface Props {
id: string id: string
entry?: MedialibEntry | null
filter?: string | null filter?: string | null
noPlaceholder?: boolean noPlaceholder?: boolean
caption?: string caption?: string
@@ -54,6 +17,7 @@
let { let {
id, id,
entry = null,
filter = null, filter = null,
noPlaceholder = false, noPlaceholder = false,
caption = "", caption = "",
@@ -64,11 +28,10 @@
style = "", style = "",
}: Props = $props() }: 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 imgEl = $state<HTMLImageElement | null>(null)
let currentFilter = $state<string>("l-webp") 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 // Sync explicit filter prop reactively
$effect(() => { $effect(() => {
@@ -94,34 +57,14 @@
return false return false
} }
async function loadFile() { function resolveFileSrc(src: string | undefined, entryId: string | undefined): string | null {
if (!id) return if (!src) return null
loading = true if (/^(https?:)?\/\//.test(src) || src.startsWith("/")) return src
entry = null if (!entryId) return null
fileSrc = null const normalizedApiBase = apiBaseURL.replace(/\/+$/, "")
try { return `${normalizedApiBase}/medialib/${entryId}/${src.replace(/^\/+/, "")}`
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
} }
// 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 // ResizeObserver: only when no explicit filter and raster image
$effect(() => { $effect(() => {
const el = imgEl const el = imgEl
@@ -147,21 +90,24 @@
if (filter) return src + `?filter=${filter}` if (filter) return src + `?filter=${filter}`
return src + `?filter=${currentFilter}` 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> </script>
{#if id} {#if effectiveId}
{#if loading} {#if entry && fileSrc}
{#if !noPlaceholder}
<img src="/assets/img/placeholder-image.svg" alt="loading" />
{/if}
{:else if entry && fileSrc}
{#if showCaption && caption} {#if showCaption && caption}
<figure> <figure>
<picture> <picture>
<img <img
bind:this={imgEl} bind:this={imgEl}
src={getSrc(fileSrc, entry)} src={getSrc(fileSrc, entry)}
alt={entry.alt || ""} alt={resolveLocalizedText(entry.alt, $currentLanguage)}
data-entry-id={id} data-entry-id={id}
loading={lazy ? "lazy" : undefined} loading={lazy ? "lazy" : undefined}
{style} {style}
@@ -176,7 +122,7 @@
<img <img
bind:this={imgEl} bind:this={imgEl}
src={getSrc(fileSrc, entry)} src={getSrc(fileSrc, entry)}
alt={entry.alt || ""} alt={resolveLocalizedText(entry.alt, $currentLanguage)}
data-entry-id={id} data-entry-id={id}
loading={lazy ? "lazy" : undefined} loading={lazy ? "lazy" : undefined}
{style} {style}
@@ -185,7 +131,7 @@
{/if} {/if}
{:else if !noPlaceholder} {:else if !noPlaceholder}
<picture> <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> </picture>
{/if} {/if}
{/if} {/if}
-1
View File
@@ -12,7 +12,6 @@
"start:mock": "MOCK=1 node scripts/esbuild-wrapper.js start", "start:mock": "MOCK=1 node scripts/esbuild-wrapper.js start",
"start:ssr": "SSR=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": "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'", "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": "playwright test",
"test:e2e": "playwright test tests/e2e", "test:e2e": "playwright test tests/e2e",
+11
View File
@@ -16,10 +16,20 @@ function log(str, clear) {
let buildResults let buildResults
let ctx 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) { async function build(catchError) {
if (config.writeBuildInfo) { if (config.writeBuildInfo) {
config.writeBuildInfo() config.writeBuildInfo()
} }
cleanChunks()
if (!ctx) ctx = await esbuild.context(config.options) if (!ctx) ctx = await esbuild.context(config.options)
log((buildResults ? "re" : "") + "building...") log((buildResults ? "re" : "") + "building...")
const timerStart = Date.now() const timerStart = Date.now()
@@ -76,6 +86,7 @@ switch (process.argv?.length > 2 ? process.argv[2] : "build") {
if (config.writeBuildInfo) { if (config.writeBuildInfo) {
config.writeBuildInfo() config.writeBuildInfo()
} }
cleanChunks()
esbuild.build(config.options).then(function (buildResults) { esbuild.build(config.options).then(function (buildResults) {
if (config.options.metafile) { if (config.options.metafile) {
fs.writeFileSync( fs.writeFileSync(
+16
View File
@@ -36,4 +36,20 @@ test.describe("Home Page", () => {
expect(page.url()).toContain("/en") 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$/)
})
}) })
+30 -27
View File
@@ -50,18 +50,39 @@ interface FileField {
size: number size: number
} }
type LocalizedText = {
[lang: string]: string | undefined
}
interface MedialibEntry { interface MedialibEntry {
id?: string id?: string
_id?: string
file?: { file?: {
src?: string src?: string
type?: string type?: string
} }
alt?: string title?: string
alt?: string | LocalizedText
description?: string
[key: string]: unknown [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 */ /** Pagebuilder: Content Block Entry */
interface ContentBlockEntry { interface ContentBlockEntry extends LookupContainer<MedialibEntry> {
hide?: boolean hide?: boolean
headline?: string headline?: string
headlineH1?: boolean headlineH1?: boolean
@@ -69,10 +90,6 @@ interface ContentBlockEntry {
tagline?: string tagline?: string
anchorId?: string anchorId?: string
containerWidth?: "" | "wide" | "full" containerWidth?: "" | "wide" | "full"
background?: {
color?: string
image?: string
}
padding?: { padding?: {
top?: string top?: string
bottom?: string bottom?: string
@@ -83,41 +100,25 @@ interface ContentBlockEntry {
buttonLink?: string buttonLink?: string
buttonTarget?: string buttonTarget?: string
} }
heroImage?: { heroImage?: ContentHeroImage
image?: string featureBoxes?: ContentFeatureBoxEntry[]
/** External image URL (e.g. Unsplash) — used when no medialib ID is available */
externalUrl?: string
}
// richtext fields // richtext fields
text?: string text?: string
imagePosition?: "none" | "left" | "right" imagePosition?: "none" | "left" | "right"
imageRounded?: string
image?: string image?: string
/** External image URL for richtext/generic blocks (e.g. Unsplash) */
externalImageUrl?: string
// accordion fields // accordion fields
accordionItems?: { accordionItems?: {
question?: string question?: string
answer?: string answer?: string
open?: boolean open?: boolean
}[] }[]
// imageGallery fields
imageGallery?: {
images?: {
image?: string
caption?: string
showCaption?: boolean
}[]
}
// richtext caption fields
showImageCaption?: boolean
imageCaption?: string
} }
/** Content Entry from the CMS */ /** Content Entry from the CMS */
interface ContentEntry { interface ContentEntry {
id?: string id?: string
_id?: string _id?: string
_lookup?: Record<string, MedialibEntry | MedialibEntry[] | null>
active?: boolean active?: boolean
publication?: { publication?: {
from?: string | Date from?: string | Date
@@ -129,13 +130,12 @@ interface ContentEntry {
name?: string name?: string
path?: string path?: string
alternativePaths?: { path?: string }[] alternativePaths?: { path?: string }[]
thumbnail?: string
teaserText?: string teaserText?: string
blocks?: ContentBlockEntry[] blocks?: ContentBlockEntry[]
meta?: { meta?: {
title?: string title?: string
description?: string description?: string
keywords?: string keywords?: string[]
} }
} }
@@ -146,6 +146,9 @@ interface NavigationElement {
page?: string page?: string
hash?: string hash?: string
externalUrl?: string externalUrl?: string
_lookup?: {
page?: ContentEntry | null
}
} }
/** Navigation entry from the CMS */ /** Navigation entry from the CMS */
+3 -3
View File
@@ -5,7 +5,7 @@ import { moveThenClick, moveThenType, smoothScroll } from "../helpers"
* Video tour: Full demo showcase * Video tour: Full demo showcase
* *
* Walks through all demo pages, demonstrating: * 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 * 2. About page — content blocks
* 3. Contact page — form interaction * 3. Contact page — form interaction
* 4. Language switching (EN ↔ DE with route translation) * 4. Language switching (EN ↔ DE with route translation)
@@ -20,11 +20,11 @@ tour("Demo Showcase", async ({ tourPage: page }) => {
await page.goto("/de/") await page.goto("/de/")
await page.waitForTimeout(2500) await page.waitForTimeout(2500)
// Scroll down through hero → features // Scroll down through hero → first richtext section
await smoothScroll(page, 500) await smoothScroll(page, 500)
await page.waitForTimeout(2000) await page.waitForTimeout(2000)
// Scroll through features // Scroll through the first richtext section
await smoothScroll(page, 1200) await smoothScroll(page, 1200)
await page.waitForTimeout(2500) await page.waitForTimeout(2500)