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