diff --git a/.agents/skills/admin-ui-config/SKILL.md b/.agents/skills/admin-ui-config/SKILL.md index edacd2f..531f9ab 100644 --- a/.agents/skills/admin-ui-config/SKILL.md +++ b/.agents/skills/admin-ui-config/SKILL.md @@ -391,7 +391,7 @@ function getRenderedElement( export { getRenderedElement } ``` -Build with `yarn build:admin`. The output is loaded by tibi-admin-nova as a custom module. +Build with `yarn build`. The output includes the admin module and is loaded by tibi-admin-nova as a custom module. **Use case:** Custom dashboard widgets, preview components, or field widgets that require Svelte rendering inside the admin UI. diff --git a/.agents/skills/content-authoring/SKILL.md b/.agents/skills/content-authoring/SKILL.md index 05b6c1c..fe65895 100644 --- a/.agents/skills/content-authoring/SKILL.md +++ b/.agents/skills/content-authoring/SKILL.md @@ -178,7 +178,6 @@ yarn validate # TypeScript check — must be warning-free | Type | Component | Purpose | | -------------- | ------------------------- | ----------------------------------------- | | `hero` | `HeroBlock.svelte` | Full-width hero with image, headline, CTA | -| `features` | `FeaturesBlock.svelte` | Feature grid with icons | | `richtext` | `RichtextBlock.svelte` | Rich text with optional image | | `accordion` | `AccordionBlock.svelte` | Expandable FAQ/accordion items | | `contact-form` | `ContactFormBlock.svelte` | Contact form | diff --git a/.agents/skills/tibi-project-setup/SKILL.md b/.agents/skills/tibi-project-setup/SKILL.md index 0737f81..a39b381 100644 --- a/.agents/skills/tibi-project-setup/SKILL.md +++ b/.agents/skills/tibi-project-setup/SKILL.md @@ -38,25 +38,23 @@ git remote rename origin template Three placeholders must be replaced in the correct files: -| Placeholder | Files | Format | Example | -| -------------------- | -------------------------------------- | --------------------------------------------------------- | ------------ | -| `__PROJECT_NAME__` | `.env` | kebab-case (used for URLs, Docker containers, subdomains) | `my-project` | -| `__TIBI_NAMESPACE__` | `.env` | snake_case (used as DB prefix and in API URLs) | `my_project` | -| `__NAMESPACE__` | `api/config.yml`, `frontend/.htaccess` | snake_case — same value as `TIBI_NAMESPACE` | `my_project` | +| Placeholder | Files | Format | Example | +| -------------------- | ---------------------------------------------- | --------------------------------------------------------- | ------------ | +| `__PROJECT_NAME__` | `.env` | kebab-case (used for URLs, Docker containers, subdomains) | `my-project` | +| `__TIBI_NAMESPACE__` | `.env`, `api/config.yml`, `frontend/.htaccess` | snake_case (used as DB prefix and in API URLs) | `my_project` | ```sh PROJECT=my-project # kebab-case NAMESPACE=my_project # snake_case sed -i "s/__PROJECT_NAME__/$PROJECT/g" .env -sed -i "s/__TIBI_NAMESPACE__/$NAMESPACE/g" .env -sed -i "s/__NAMESPACE__/$NAMESPACE/g" api/config.yml frontend/.htaccess +sed -i "s/__TIBI_NAMESPACE__/$NAMESPACE/g" .env api/config.yml frontend/.htaccess ``` **Verify each replacement:** ```sh -grep -n '__PROJECT_NAME__\|__TIBI_NAMESPACE__\|__NAMESPACE__' .env api/config.yml frontend/.htaccess +grep -n '__PROJECT_NAME__\|__TIBI_NAMESPACE__' .env api/config.yml frontend/.htaccess # Expected: no output (all placeholders replaced) ``` @@ -73,7 +71,7 @@ STAGING_URL=https://dev-my-project.staging.testversion.online - **Mixing formats**: `PROJECT` must be kebab-case (`my-project`), `NAMESPACE` must be snake_case (`my_project`). Never use kebab-case where snake_case is expected or vice versa. - **Forgetting `frontend/.htaccess`**: Contains the namespace for API rewrite rules. If missed, API calls from the frontend will fail silently. -- **Forgetting `api/config.yml`**: First line is `namespace: __NAMESPACE__`. If not replaced, tibi-server won't start correctly. +- **Forgetting `api/config.yml`**: First line is `namespace: __TIBI_NAMESPACE__`. If not replaced, tibi-server won't start correctly. ## Step 3 — Page title @@ -146,7 +144,7 @@ For a real project, remove or replace the demo files: | File/Folder | Content | | ---------------------------------- | ------------------------------------------------------ | -| `frontend/src/blocks/` | Demo block components (HeroBlock, FeaturesBlock, etc.) | +| `frontend/src/blocks/` | Demo block components (HeroBlock, RichtextBlock, etc.) | | `frontend/mocking/content.json` | Demo mock data for content | | `frontend/mocking/navigation.json` | Demo mock data for navigation | | `api/collections/content.yml` | Content collection config | @@ -166,7 +164,7 @@ Then adapt `frontend/src/App.svelte` (header, footer, content loading) to your o ```sh yarn build # Frontend bundle for modern browsers yarn build:server # SSR bundle (for tibi-server goja hooks) -yarn build:admin # Admin modules (optional) +yarn build # Frontend + admin module yarn validate # TypeScript + Svelte checks (must show 0 errors and 0 warnings) ``` diff --git a/.env b/.env index a8df2a9..66ba2cd 100644 --- a/.env +++ b/.env @@ -22,4 +22,4 @@ STAGING_URL=https://dev-__PROJECT_NAME__.staging.testversion.online CODING_URL=https://__PROJECT_NAME__.code.testversion.online #START_SCRIPT=:ssr -MOCK=1 +#MOCK=1 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index ed1378c..cc19b00 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -39,12 +39,6 @@ jobs: run: | yarn build - - name: build admin - env: - FORCE_COLOR: "true" - run: | - yarn build:admin - - name: build ssr env: FORCE_COLOR: "true" diff --git a/.vscode/settings.json b/.vscode/settings.json index 9279254..af40fa0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,12 +5,12 @@ "editor.formatOnPaste": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "yaml.schemas": { - "./../../cms/tibi-types/schemas/api-config/config.json": "api/config.y*ml", - "./../../cms/tibi-types/schemas/api-config/collection.json": "api/collections/*.y*ml", - "./../../cms/tibi-types/schemas/api-config/field.json": "api/collections/fields/*.y*ml", - "./../../cms/tibi-types/schemas/api-config/fieldArray.json": "api/collections/fieldLists/*.y*ml", - "./../../cms/tibi-types/schemas/api-config/job.json": "api/jobs/*.y*ml", - "./../../cms/tibi-types/schemas/api-config/assets.json": "api/assets/*.y*ml" + "./../../cms/tibi-types/schemas/config/project.schema.json": "api/config.y*ml", + "./../../cms/tibi-types/schemas/config/collection.schema.json": "api/collections/*.y*ml", + "./../../cms/tibi-types/schemas/config/field.schema.json": "api/collections/fields/*.y*ml", + "./../../cms/tibi-types/schemas/config/field-list.schema.json": "api/collections/fieldLists/*.y*ml", + "./../../cms/tibi-types/schemas/config/job.schema.json": "api/jobs/*.y*ml", + "./../../cms/tibi-types/schemas/config/asset.schema.json": "api/assets/*.y*ml" }, "yaml.customTags": ["!include scalar"], "filewatcher.commands": [ diff --git a/Makefile b/Makefile index 11b32ec..3a40137 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ docker-logs-%: ## show last X lines of docker logs $(DOCKER_COMPOSE) --profile tibi-dev --profile tibi --profile chisel logs --tail=$* docker-pull: ## pull docker images - $(DOCKER_COMPOSE) --profile tibi-dev --profile tibi --profile chisel pull + $(DOCKER_COMPOSE) --profile tibi --profile chisel pull docker-%: $(DOCKER_COMPOSE) $* diff --git a/README.md b/README.md index 7a0655b..99e3302 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Starter Kit für SPAs(s) `;)` mit Svelte und TibiCMS inkl. SSR [ ] Repository geklont und Remotes konfiguriert [ ] __PROJECT_NAME__ in .env ersetzt (kebab-case) [ ] __TIBI_NAMESPACE__ in .env ersetzt (snake_case) -[ ] __NAMESPACE__ in api/config.yml und frontend/.htaccess ersetzt +[ ] __TIBI_NAMESPACE__ in api/config.yml und frontend/.htaccess ersetzt [ ] Keine verbleibenden __*__-Platzhalter (mit grep prüfen) [ ] App.svelte hat mit [ ] ADMIN_TOKEN in api/config.yml.env gesetzt @@ -33,8 +33,4 @@ Die Navigation innerhalb der App löst nur API-Aufrufe aus, ohne jedes Mal SSR a - `<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`. diff --git a/api/collections/content.yml b/api/collections/content.yml index b08adc5..3e96e4b 100644 --- a/api/collections/content.yml +++ b/api/collections/content.yml @@ -6,24 +6,28 @@ name: content meta: label: { de: "Inhalte", en: "Content" } muiIcon: article - rowIdentTpl: { twig: "{{ name }}" } - - views: - - type: simpleList - mediaQuery: "(max-width: 600px)" - primaryText: name - secondaryText: lang - tertiaryText: path - - type: table - columns: - - name - - source: lang - filter: true - - source: type - filter: true - - source: path - - source: active - filter: true + group: content + preview: + label: name + secondary: path + tertiary: lang + badge: type + image: _pagebuilderThumbnail + pagebuilder: + screenshot: + - field: blocks + fileField: _pagebuilderThumbnail + i18n: + entry: + languageField: lang + groupField: translationKey + sidebar: + - group: publishing + label: { de: "Veröffentlichung", en: "Publishing" } + - group: settings + label: { de: "Einstellungen", en: "Settings" } + - group: seo + label: { de: "SEO", en: "SEO" } permissions: public: @@ -41,18 +45,22 @@ fields: type: boolean meta: label: { de: "Aktiv", en: "Active" } + position: sidebar:publishing - name: type type: string meta: label: { de: "Typ", en: "Type" } + position: sidebar:settings - name: lang type: string meta: label: { de: "Sprache", en: "Language" } + position: sidebar:settings - name: translationKey type: string meta: label: { de: "Übersetzungsschlüssel", en: "Translation Key" } + position: sidebar:settings - name: name type: string meta: @@ -68,104 +76,380 @@ fields: subFields: - name: path type: string - - name: thumbnail - type: string - meta: - label: { de: "Vorschaubild", en: "Thumbnail" } - name: teaserText type: string meta: label: { de: "Teasertext", en: "Teaser Text" } + - name: _pagebuilderThumbnail + type: file + meta: + hide: true - name: blocks type: object[] meta: label: { de: "Inhaltsblöcke", en: "Content Blocks" } + position: main + drillDown: false + widget: pagebuilder + pagebuilder: + blockTypeField: type + defaultViewport: desktop + blockRegistry: + file: /_/assets/dist/admin.mjs subFields: - name: hide type: boolean + meta: + label: { de: "Block ausblenden", en: "Hide Block" } + dependsOn: + eval: $parent.type != '' + containerProps: + layout: + size: col-4 - name: type type: string + meta: + label: { de: "Blocktyp", en: "Block Type" } + widget: select + defaultValue: richtext + helperText: + de: "Wähle zuerst den Blocktyp. Danach werden nur die passenden Felder angezeigt." + en: "Choose the block type first. Then only the relevant fields are shown." + containerProps: + layout: + size: col-8 + choices: + - id: hero + name: { de: "Hero", en: "Hero" } + description: + { + de: "Hero mit Bild und Call-to-Action.", + en: "Hero with image and call to action.", + } + - id: features + name: { de: "Features", en: "Features" } + description: + { + de: "Feature-Übersicht mit strukturierten Boxen.", + en: "Feature overview with structured boxes.", + } + - id: richtext + name: { de: "Richtext", en: "Richtext" } + description: + { + de: "Fließtext optional mit Bild.", + en: "Body text optionally with image.", + } + - id: accordion + name: { de: "Akkordeon", en: "Accordion" } + description: + { + de: "Aufklappbare Fragen und Antworten.", + en: "Expandable questions and answers.", + } + - id: contact-form + name: { de: "Kontaktformular", en: "Contact Form" } + description: + { + de: "Kontaktformular mit Intro-Text.", + en: "Contact form with intro text.", + } - name: headline type: string + meta: + label: { de: "Überschrift", en: "Headline" } + dependsOn: + eval: $parent.type == 'hero' || $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' || $parent.type == 'contact-form' + containerProps: + layout: + size: col-8 - name: headlineH1 type: boolean + meta: + label: { de: "Als H1 rendern", en: "Render as H1" } + dependsOn: + eval: $parent.type == 'hero' + containerProps: + layout: + size: col-4 - name: subline type: string + meta: + label: { de: "Unterzeile", en: "Subline" } + dependsOn: + eval: $parent.type == 'hero' + inputProps: + multiline: true + rows: 3 - name: tagline type: string + meta: + label: { de: "Dachzeile", en: "Tagline" } + dependsOn: + eval: $parent.type == 'hero' || $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' + containerProps: + layout: + size: col-6 - name: anchorId type: string + meta: + label: { de: "Anker-ID", en: "Anchor ID" } + dependsOn: + eval: $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' || $parent.type == 'contact-form' + containerProps: + layout: + size: col-6 - name: containerWidth type: string - - name: background - type: object - subFields: - - name: color - type: string - - name: image - type: string + meta: + label: { de: "Containerbreite", en: "Container Width" } + widget: select + dependsOn: + eval: $parent.type == 'hero' + containerProps: + layout: + size: col-6 + choices: + - id: "" + name: { de: "Standard", en: "Default" } + - id: wide + name: { de: "Breit", en: "Wide" } + - id: full + name: { de: "Volle Breite", en: "Full Width" } - name: padding type: object + meta: + label: { de: "Innenabstand", en: "Padding" } + dependsOn: + eval: $parent.type == 'features' || $parent.type == 'richtext' || $parent.type == 'accordion' || $parent.type == 'contact-form' + drillDown: false + containerProps: + layout: + size: col-6 subFields: - name: top type: string + meta: + label: { de: "Oben", en: "Top" } + widget: select + hideLabel: true + containerProps: + layout: + size: col-6 + choices: + - id: "" + name: { de: "Standard", en: "Default" } + - id: sm + name: { de: "Klein", en: "Small" } + - id: md + name: { de: "Mittel", en: "Medium" } + - id: lg + name: { de: "Groß", en: "Large" } - name: bottom type: string + meta: + label: { de: "Unten", en: "Bottom" } + widget: select + hideLabel: true + containerProps: + layout: + size: col-6 + choices: + - id: "" + name: { de: "Standard", en: "Default" } + - id: sm + name: { de: "Klein", en: "Small" } + - id: md + name: { de: "Mittel", en: "Medium" } + - id: lg + name: { de: "Groß", en: "Large" } - name: callToAction type: object + meta: + label: { de: "Call-to-Action", en: "Call to Action" } + dependsOn: + eval: $parent.type == 'hero' + drillDown: false subFields: - name: buttonText type: string + meta: + label: { de: "Button-Text", en: "Button Text" } + containerProps: + layout: + size: col-4 - name: buttonLink type: string + meta: + label: { de: "Button-Link", en: "Button Link" } + containerProps: + layout: + size: col-5 - name: buttonTarget type: string + meta: + label: { de: "Link-Target", en: "Link Target" } + widget: select + containerProps: + layout: + size: col-3 + choices: + - id: "" + name: { de: "Gleiches Fenster", en: "Same Window" } + - id: _blank + name: { de: "Neuer Tab", en: "New Tab" } - name: heroImage type: object + meta: + label: { de: "Hero-Bild", en: "Hero Image" } + dependsOn: + eval: $parent.type == 'hero' + drillDown: false subFields: - name: image type: string + meta: + label: { de: "Bild", en: "Image" } + widget: foreignMedia + foreign: + collection: medialib + id: _id + subNavigation: 0 - name: text type: string + meta: + label: { de: "Text", en: "Text" } + dependsOn: + eval: $parent.type == 'richtext' + widget: richtext + inputProps: + rows: 8 + - name: featureBoxes + type: object[] + meta: + label: { de: "Feature-Boxen", en: "Feature Boxes" } + dependsOn: + eval: $parent.type == 'features' + drillDown: false + preview: title + subFields: + - name: icon + type: string + meta: + label: { de: "Icon", en: "Icon" } + widget: select + containerProps: + layout: + size: col-4 + choices: + - id: lightning + name: { de: "Blitz", en: "Lightning" } + - id: palette + name: { de: "Palette", en: "Palette" } + - id: database + name: { de: "Daten", en: "Data" } + - id: globe + name: { de: "Globus", en: "Globe" } + - id: monitor + name: { de: "Monitor", en: "Monitor" } + - id: flask + name: { de: "Labor", en: "Lab" } + - name: title + type: string + meta: + label: { de: "Titel", en: "Title" } + containerProps: + layout: + size: col-8 + - name: text + type: string + meta: + label: { de: "Text", en: "Text" } + inputProps: + multiline: true + rows: 4 - name: imagePosition type: string - - name: imageRounded - type: string + meta: + label: { de: "Bildposition", en: "Image Position" } + widget: select + dependsOn: + eval: $parent.type == 'richtext' + containerProps: + layout: + size: col-4 + choices: + - id: none + name: { de: "Kein Bild", en: "No Image" } + - id: left + name: { de: "Links", en: "Left" } + - id: right + name: { de: "Rechts", en: "Right" } - name: image type: string + meta: + label: { de: "Bild", en: "Image" } + dependsOn: + eval: $parent.type == 'richtext' + containerProps: + layout: + size: col-4 + widget: foreignMedia + foreign: + collection: medialib + id: _id + subNavigation: 0 - name: accordionItems type: object[] + meta: + label: { de: "Akkordeon-Elemente", en: "Accordion Items" } + dependsOn: + eval: $parent.type == 'accordion' + drillDown: true subFields: - name: question type: string + meta: + label: { de: "Frage", en: "Question" } + containerProps: + layout: + size: col-8 - name: answer type: string + meta: + label: { de: "Antwort", en: "Answer" } + widget: richtext + inputProps: + rows: 6 + containerProps: + layout: + breakAfter: true - name: open type: boolean - - name: imageGallery - type: object - subFields: - - name: images - type: object[] - subFields: - - name: image - type: string - - name: caption - type: string - - name: showCaption - type: boolean - - name: showImageCaption - type: boolean - - name: imageCaption - type: string + meta: + label: { de: "Initial geöffnet", en: "Initially Open" } + containerProps: + layout: + size: col-4 - name: meta type: object meta: + widget: containerLessObject label: { de: "SEO", en: "SEO" } + position: sidebar:seo subFields: - name: title type: string + meta: + label: { de: "Meta-Titel", en: "Meta Title" } - name: description type: string + meta: + label: { de: "Meta-Beschreibung", en: "Meta Description" } + inputProps: + multiline: true + rows: 3 - name: keywords - type: string + type: string[] + meta: + label: { de: "Meta-Schlüsselwörter", en: "Meta Keywords" } diff --git a/api/collections/medialib.yml b/api/collections/medialib.yml new file mode 100644 index 0000000..7503bcb --- /dev/null +++ b/api/collections/medialib.yml @@ -0,0 +1,120 @@ +######################################################################## +# Media library — reusable uploaded assets referenced via foreignMedia +######################################################################## + +name: medialib +meta: + label: { de: "Mediathek", en: "Media Library" } + muiIcon: image_multiple + group: media + viewHint: + media: + ai: + targetField: alt + prompt: Beschreibe das Bild kurz und sachlich fuer einen barrierefreien Alt-Text. + image: + maxWidth: 1280 + maxHeight: 1280 + quality: 0.82 + upload: + autoFill: + file: file + preview: + label: title + secondary: description + tertiary: tags + image: file + table: + - file + - title + - tags + subNavigation: + - name: images + label: { de: "Bilder", en: "Images" } + muiIcon: image + filter: + file.type_like: ^image/ + - name: documents + label: { de: "Dokumente", en: "Documents" } + muiIcon: file_document + filter: + file.type_like: ^(?!image/|video/|audio/).+ + +permissions: + public: + methods: + get: true + user: + methods: + get: true + post: true + put: true + delete: true + +imageFilter: + xs-webp: + - width: 100 + height: 100 + fit: true + quality: 60 + outputType: webp + s-webp: + - width: 300 + quality: 70 + outputType: webp + m-webp: + - width: 600 + quality: 76 + outputType: webp + l-webp: + - width: 1200 + quality: 80 + outputType: webp + xl-webp: + - width: 2000 + quality: 84 + outputType: webp + xxl-webp: + - width: 2800 + quality: 88 + outputType: webp + +fields: + - name: file + type: file + meta: + label: { de: "Datei", en: "File" } + widget: file + downscale: + maxWidth: 2048 + maxHeight: 2048 + - name: title + type: string + meta: + label: { de: "Titel", en: "Title" } + - name: alt + type: object + meta: + label: { de: "Alt-Text", en: "Alt Text" } + subFields: + - name: de + type: string + meta: + label: Deutsch + - name: en + type: string + meta: + label: English + - name: description + type: string + meta: + label: { de: "Beschreibung", en: "Description" } + widget: text + inputProps: + multiline: true + rows: 4 + - name: tags + type: string[] + meta: + label: { de: "Tags", en: "Tags" } + widget: chipArray diff --git a/api/collections/navigation.yml b/api/collections/navigation.yml index 5cc588b..5d6b2c5 100644 --- a/api/collections/navigation.yml +++ b/api/collections/navigation.yml @@ -6,19 +6,45 @@ name: navigation meta: label: { de: "Navigation", en: "Navigation" } muiIcon: menu - rowIdentTpl: { twig: "{{ type }} ({{ language }})" } - - views: - - type: simpleList - mediaQuery: "(max-width: 600px)" - primaryText: type - secondaryText: language - - type: table - columns: - - source: type - filter: true - - source: language - filter: true + group: structure + viewHint: + navigation: + nodesField: elements + preview: + label: name + secondary: + eval: "$this.external && $this.externalUrl ? $this.externalUrl : ($this._lookup?.page ? $this._lookup.page.name + ' (' + $this._lookup.page.path + ')' : '')" + select: [external, externalUrl, page] + declaredTrees: + - label: { de: "Header DE", en: "Header DE" } + singleton: + type: header + language: de + maxLevel: 2 + - label: { de: "Header EN", en: "Header EN" } + singleton: + type: header + language: en + maxLevel: 2 + - label: { de: "Footer DE", en: "Footer DE" } + singleton: + type: footer + language: de + maxLevel: 1 + - label: { de: "Footer EN", en: "Footer EN" } + singleton: + type: footer + language: en + maxLevel: 1 + preview: + label: type + secondary: language + table: + - type + - language + sidebar: + - group: settings + label: { de: "Einstellungen", en: "Settings" } permissions: public: @@ -36,15 +62,30 @@ fields: type: string meta: label: { de: "Sprache", en: "Language" } + position: sidebar:settings + widget: select + choices: + - id: de + name: { de: "Deutsch", en: "German" } + - id: en + name: { de: "Englisch", en: "English" } - name: type type: string meta: label: { de: "Typ", en: "Type" } helperText: { de: "header oder footer", en: "header or footer" } + position: sidebar:settings + widget: select + choices: + - id: header + name: { de: "Header", en: "Header" } + - id: footer + name: { de: "Footer", en: "Footer" } - name: elements type: object[] meta: label: { de: "Elemente", en: "Elements" } + preview: name subFields: - name: name type: string @@ -53,7 +94,11 @@ fields: - name: page type: string meta: - label: { de: "Seite (Content-ID)", en: "Page (Content ID)" } + label: { de: "Seite", en: "Page" } + widget: foreignKey + foreign: + collection: content + id: id - name: external type: boolean meta: @@ -66,3 +111,7 @@ fields: type: string meta: label: { de: "Anker", en: "Anchor" } + - name: elements + type: object[] + meta: + label: { de: "Unterpunkte", en: "Child Items" } diff --git a/api/collections/ssr.yml b/api/collections/ssr.yml index 64037a7..09803a6 100644 --- a/api/collections/ssr.yml +++ b/api/collections/ssr.yml @@ -6,22 +6,8 @@ name: ssr meta: label: { de: "SSR Dummy", en: "ssr dummy" } muiIcon: server - rowIdentTpl: { twig: "{{ id }}" } - - views: - - type: simpleList - mediaQuery: "(max-width: 600px)" - primaryText: id - secondaryText: insertTime - tertiaryText: path - - type: table - columns: - - id - - insertTime - - source: path - filter: true - - source: validUntil - - dependencies + group: system + hide: true permissions: public: diff --git a/api/config.yml b/api/config.yml index 1c61b94..60f7123 100644 --- a/api/config.yml +++ b/api/config.yml @@ -1,15 +1,32 @@ -namespace: __NAMESPACE__ +namespace: __TIBI_NAMESPACE__ meta: imageUrl: - eval: "$projectBase + '_/assets/img/admin-pic.jpg'" - injectIntoHead: - # inject font faces (not possible in shadow dom for preview) - eval: | - "<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 diff --git a/esbuild.config.js b/esbuild.config.js index accb2d5..c9f18f8 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -96,13 +96,16 @@ const esbuildSvelte = sveltePlugin({ const options = { logLevel: "info", color: true, - entryPoints: ["./frontend/src/index.ts"], - outfile: distDir + "/index.mjs", + entryPoints: ["./frontend/src/index.ts", "./frontend/src/admin.ts"], + outdir: distDir, + entryNames: "[name]", + chunkNames: "chunks/[name]-[hash]", + outExtension: { ".js": ".mjs" }, metafile: true, format: "esm", minify: process.argv[2] == "build", bundle: true, - splitting: false, + splitting: true, define: { __MOCK__: process.env.MOCK === "1" ? "true" : "false", }, diff --git a/frontend/.htaccess b/frontend/.htaccess index 6120167..b2ed3ff 100644 --- a/frontend/.htaccess +++ b/frontend/.htaccess @@ -10,7 +10,7 @@ SetEnv MATOMO no RewriteEngine On RewriteBase / - RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__NAMESPACE__/$1 [P,QSA,L] + RewriteRule ^/?api/(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/$1 [P,QSA,L] # Set the Host header for requests to sentry RequestHeader set Host sentry.basehosts.de env=proxy-sentry @@ -36,7 +36,7 @@ SetEnv MATOMO no RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr] + RewriteRule ^/?(.*)$ http://tibi-server:8080/api/v1/_/__TIBI_NAMESPACE__/ssr [P,QSA,L,E=proxy-ssr] # RewriteRule (.*) /spa.html [QSA,L] </ifModule> diff --git a/frontend/assets/fonts/.gitkeep b/frontend/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/assets/img/admin-pic.svg b/frontend/assets/img/admin-pic.svg new file mode 100644 index 0000000..1a4e32b --- /dev/null +++ b/frontend/assets/img/admin-pic.svg @@ -0,0 +1,29 @@ +<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 + Abstract admin preview illustration for the Tibi starter project. + + + + + + + + + + + + + + + + + + + + + + + + Tibi Admin Nova Starter + Lean starter base with content, media, i18n, and SSR. + diff --git a/frontend/assets/img/placeholder-image.svg b/frontend/assets/img/placeholder-image.svg new file mode 100644 index 0000000..5bd0e60 --- /dev/null +++ b/frontend/assets/img/placeholder-image.svg @@ -0,0 +1,16 @@ + + Placeholder image + Neutral placeholder graphic for missing media assets. + + + + + + + + + + + + + diff --git a/frontend/assets/img/safari-pinned-tab.svg b/frontend/assets/img/safari-pinned-tab.svg new file mode 100644 index 0000000..fdbef6e --- /dev/null +++ b/frontend/assets/img/safari-pinned-tab.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/img/site.webmanifest b/frontend/assets/img/site.webmanifest new file mode 100644 index 0000000..6eb94ef --- /dev/null +++ b/frontend/assets/img/site.webmanifest @@ -0,0 +1,8 @@ +{ + "name": "Tibi Svelte Starter", + "short_name": "Tibi Starter", + "icons": [], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/frontend/mocking/content.json b/frontend/mocking/content.json index d19557f..b768500 100644 --- a/frontend/mocking/content.json +++ b/frontend/mocking/content.json @@ -1,7 +1,8 @@ [ { - "id": "home-de", - "_id": "home-de", + "_id": { + "$oid": "6821c0a10000000000000001" + }, "active": true, "type": "page", "lang": "de", @@ -21,19 +22,50 @@ "buttonTarget": "" }, "heroImage": { - "externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80" + "image": "6821c0a10000000000000201" } }, { "type": "features", "headline": "Was dieses Template kann", - "tagline": "Features", + "tagline": "Highlights", "anchorId": "features", "padding": { "top": "lg", "bottom": "lg" }, - "text": "

Svelte 5 Runes

Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance.

Tailwind CSS 4

Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds.

Tibi CMS API

Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung.

i18n Built-in

Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel.

SSR via goja

Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung.

Playwright Tests

E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go.

" + "featureBoxes": [ + { + "icon": "lightning", + "title": "Svelte 5 Runes", + "text": "Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance." + }, + { + "icon": "palette", + "title": "Tailwind CSS 4", + "text": "Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds." + }, + { + "icon": "database", + "title": "Tibi CMS API", + "text": "Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung." + }, + { + "icon": "globe", + "title": "Mehrsprachig", + "text": "Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel." + }, + { + "icon": "monitor", + "title": "SSR via goja", + "text": "Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung." + }, + { + "icon": "flask", + "title": "Playwright Tests", + "text": "E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go." + } + ] }, { "type": "richtext", @@ -44,9 +76,9 @@ "top": "lg", "bottom": "sm" }, - "externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80", + "image": "6821c0a10000000000000202", "imagePosition": "right", - "text": "

Starte die Entwicklungsumgebung mit make docker-up && make docker-start. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.

Für Offline-Entwicklung aktiviere den Mock-Modus mit MOCK=1 in der .env. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.

Jeder Block-Typ (Hero, Richtext, Accordion, Features) ist eine eigene Svelte-Komponente — erweiterbar und austauschbar.

" + "text": "

Starte die Entwicklungsumgebung mit make docker-up && make docker-start. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.

Für Offline-Entwicklung aktiviere den Mock-Modus mit MOCK=1 in der .env. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.

Jeder Block-Typ (Hero, Richtext, Accordion, Kontaktformular) ist eine eigene Svelte-Komponente — erweiterbar und austauschbar.

" }, { "type": "accordion", @@ -81,12 +113,19 @@ "meta": { "title": "Tibi Svelte Starter — Modernes CMS-Template", "description": "Svelte 5, Tailwind CSS 4, SSR, i18n und Playwright-Tests — das perfekte Starterkit.", - "keywords": "svelte, tibi, cms, starter, template" + "keywords": [ + "svelte", + "tibi", + "cms", + "starter", + "template" + ] } }, { - "id": "home-en", - "_id": "home-en", + "_id": { + "$oid": "6821c0a10000000000000002" + }, "active": true, "type": "page", "lang": "en", @@ -106,7 +145,7 @@ "buttonTarget": "" }, "heroImage": { - "externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80" + "image": "6821c0a10000000000000201" } }, { @@ -118,7 +157,38 @@ "top": "lg", "bottom": "lg" }, - "text": "

Svelte 5 Runes

Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance.

Tailwind CSS 4

Utility-first styling with custom theme, dark-mode-ready and blazing fast builds.

Tibi CMS API

Collections, hooks, media library — all via REST API. With mock mode for offline development.

Built-in i18n

Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible.

SSR via goja

Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation.

Playwright Tests

E2E, API, visual regression and video tours — all preconfigured and ready to go.

" + "featureBoxes": [ + { + "icon": "lightning", + "title": "Svelte 5 Runes", + "text": "Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance." + }, + { + "icon": "palette", + "title": "Tailwind CSS 4", + "text": "Utility-first styling with custom theme, dark-mode-ready and blazing fast builds." + }, + { + "icon": "database", + "title": "Tibi CMS API", + "text": "Collections, hooks, media library — all via REST API. With mock mode for offline development." + }, + { + "icon": "globe", + "title": "Built-in i18n", + "text": "Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible." + }, + { + "icon": "monitor", + "title": "SSR via goja", + "text": "Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation." + }, + { + "icon": "flask", + "title": "Playwright Tests", + "text": "E2E, API, visual regression and video tours — all preconfigured and ready to go." + } + ] }, { "type": "richtext", @@ -129,9 +199,9 @@ "top": "lg", "bottom": "sm" }, - "externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80", + "image": "6821c0a10000000000000202", "imagePosition": "right", - "text": "

Start the dev environment with make docker-up && make docker-start. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.

For offline development, enable mock mode with MOCK=1 in .env. Content is loaded via the Tibi API and rendered with the BlockRenderer.

Each block type (Hero, Richtext, Accordion, Features) is its own Svelte component — extensible and swappable.

" + "text": "

Start the dev environment with make docker-up && make docker-start. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.

For offline development, enable mock mode with MOCK=1 in .env. Content is loaded via the Tibi API and rendered with the BlockRenderer.

Each block type (Hero, Richtext, Accordion, Contact Form) is its own Svelte component — extensible and swappable.

" }, { "type": "accordion", @@ -166,12 +236,19 @@ "meta": { "title": "Tibi Svelte Starter — Modern CMS Template", "description": "Svelte 5, Tailwind CSS 4, SSR, i18n and Playwright tests — the perfect starter kit.", - "keywords": "svelte, tibi, cms, starter, template" + "keywords": [ + "svelte", + "tibi", + "cms", + "starter", + "template" + ] } }, { - "id": "about-de", - "_id": "about-de", + "_id": { + "$oid": "6821c0a10000000000000003" + }, "active": true, "type": "page", "lang": "de", @@ -186,7 +263,7 @@ "subline": "Gebaut für Teams, die schnell professionelle Webprojekte umsetzen wollen.", "containerWidth": "full", "heroImage": { - "externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80" + "image": "6821c0a10000000000000203" } }, { @@ -205,7 +282,7 @@ "top": "md", "bottom": "lg" }, - "externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80", + "image": "6821c0a10000000000000204", "imagePosition": "left", "text": "

Jede Komponente wurde sorgfältig ausgewählt:

  • Svelte 5 — Reaktives Framework mit Runes-API
  • Tailwind CSS 4 — Utility-first CSS mit @theme
  • esbuild — Extrem schneller Bundler
  • Tibi CMS — Headless CMS mit Go-Backend
  • goja SSR — Server-Side Rendering in Go
  • Playwright — Modernes Testing-Framework
" } @@ -213,12 +290,18 @@ "meta": { "title": "Über das Template — Tibi Svelte Starter", "description": "Architektur und Tech-Stack des Tibi Svelte Starter Templates.", - "keywords": "svelte, über uns, template, architektur" + "keywords": [ + "svelte", + "über uns", + "template", + "architektur" + ] } }, { - "id": "about-en", - "_id": "about-en", + "_id": { + "$oid": "6821c0a10000000000000004" + }, "active": true, "type": "page", "lang": "en", @@ -233,7 +316,7 @@ "subline": "Built for teams who want to ship professional web projects fast.", "containerWidth": "full", "heroImage": { - "externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80" + "image": "6821c0a10000000000000203" } }, { @@ -252,7 +335,7 @@ "top": "md", "bottom": "lg" }, - "externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80", + "image": "6821c0a10000000000000204", "imagePosition": "left", "text": "

Every component was carefully chosen:

  • Svelte 5 — Reactive framework with Runes API
  • Tailwind CSS 4 — Utility-first CSS with @theme
  • esbuild — Extremely fast bundler
  • Tibi CMS — Headless CMS with Go backend
  • goja SSR — Server-side rendering in Go
  • Playwright — Modern testing framework
" } @@ -260,12 +343,18 @@ "meta": { "title": "About the Template — Tibi Svelte Starter", "description": "Architecture and tech stack of the Tibi Svelte Starter Template.", - "keywords": "svelte, about, template, architecture" + "keywords": [ + "svelte", + "about", + "template", + "architecture" + ] } }, { - "id": "contact-de", - "_id": "contact-de", + "_id": { + "$oid": "6821c0a10000000000000005" + }, "active": true, "type": "page", "lang": "de", @@ -280,7 +369,7 @@ "subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!", "containerWidth": "full", "heroImage": { - "externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80" + "image": "6821c0a10000000000000205" } }, { @@ -295,12 +384,16 @@ "meta": { "title": "Kontakt — Tibi Svelte Starter", "description": "Nimm Kontakt mit uns auf.", - "keywords": "kontakt, anfrage" + "keywords": [ + "kontakt", + "anfrage" + ] } }, { - "id": "contact-en", - "_id": "contact-en", + "_id": { + "$oid": "6821c0a10000000000000006" + }, "active": true, "type": "page", "lang": "en", @@ -315,7 +408,7 @@ "subline": "Questions, feedback or project inquiries? Get in touch!", "containerWidth": "full", "heroImage": { - "externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80" + "image": "6821c0a10000000000000205" } }, { @@ -330,7 +423,10 @@ "meta": { "title": "Contact — Tibi Svelte Starter", "description": "Get in touch with us.", - "keywords": "contact, inquiry" + "keywords": [ + "contact", + "inquiry" + ] } } ] \ No newline at end of file diff --git a/frontend/mocking/medialib.json b/frontend/mocking/medialib.json new file mode 100644 index 0000000..44c4740 --- /dev/null +++ b/frontend/mocking/medialib.json @@ -0,0 +1,97 @@ +[ + { + "_id": { + "$oid": "6821c0a10000000000000201" + }, + "file": { + "src": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80&auto=format&fit=crop", + "type": "image/jpeg" + }, + "title": "Homepage Hero", + "alt": { + "de": "Abstrakte Ansicht einer vernetzten digitalen Weltkarte", + "en": "Abstract view of a connected digital world map" + }, + "description": "Hero image for the homepage demo.", + "tags": [ + "hero", + "homepage" + ] + }, + { + "_id": { + "$oid": "6821c0a10000000000000202" + }, + "file": { + "src": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&q=80&auto=format&fit=crop", + "type": "image/jpeg" + }, + "title": "Workflow", + "alt": { + "de": "Laptop mit geoeffnetem Code-Editor auf einem Schreibtisch", + "en": "Laptop with an open code editor on a desk" + }, + "description": "Workflow image for the richtext demo block.", + "tags": [ + "workflow", + "development" + ] + }, + { + "_id": { + "$oid": "6821c0a10000000000000203" + }, + "file": { + "src": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80&auto=format&fit=crop", + "type": "image/jpeg" + }, + "title": "About Team", + "alt": { + "de": "Team sitzt gemeinsam an einem grossen Tisch und arbeitet zusammen", + "en": "Team working together around a large table" + }, + "description": "Hero image for the about page demo.", + "tags": [ + "about", + "team" + ] + }, + { + "_id": { + "$oid": "6821c0a10000000000000204" + }, + "file": { + "src": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&q=80&auto=format&fit=crop", + "type": "image/jpeg" + }, + "title": "Technology Stack", + "alt": { + "de": "Nahaufnahme eines Bildschirms mit Code und farbiger Syntaxhervorhebung", + "en": "Close-up of a screen with code and colorful syntax highlighting" + }, + "description": "Technology stack image for the about page demo.", + "tags": [ + "about", + "code" + ] + }, + { + "_id": { + "$oid": "6821c0a10000000000000205" + }, + "file": { + "src": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80&auto=format&fit=crop", + "type": "image/jpeg" + }, + "title": "Contact Hero", + "alt": { + "de": "Arbeitsplatz mit Laptop, Telefon und Notizbuch fuer Kontaktanfragen", + "en": "Workspace with laptop, phone, and notebook for contact inquiries" + }, + "description": "Hero image for the contact page demo.", + "tags": [ + "contact", + "hero" + ] + } +] \ No newline at end of file diff --git a/frontend/mocking/navigation.json b/frontend/mocking/navigation.json index e198d9e..c73fe4c 100644 --- a/frontend/mocking/navigation.json +++ b/frontend/mocking/navigation.json @@ -1,48 +1,96 @@ [ { - "id": "header-de", - "_id": "header-de", + "_id": { + "$oid": "6821c0a10000000000000101" + }, "language": "de", "type": "header", "elements": [ - { "name": "Startseite", "page": "/" }, - { "name": "Über uns", "page": "/ueber-uns" }, - { "name": "Kontakt", "page": "/kontakt" } + { + "name": "Startseite", + "page": "6821c0a10000000000000001" + }, + { + "name": "Über uns", + "page": "6821c0a10000000000000003" + }, + { + "name": "Kontakt", + "page": "6821c0a10000000000000005" + } ] }, { - "id": "header-en", - "_id": "header-en", + "_id": { + "$oid": "6821c0a10000000000000102" + }, "language": "en", "type": "header", "elements": [ - { "name": "Home", "page": "/" }, - { "name": "About", "page": "/about" }, - { "name": "Contact", "page": "/contact" } + { + "name": "Home", + "page": "6821c0a10000000000000002" + }, + { + "name": "About", + "page": "6821c0a10000000000000004" + }, + { + "name": "Contact", + "page": "6821c0a10000000000000006" + } ] }, { - "id": "footer-de", - "_id": "footer-de", + "_id": { + "$oid": "6821c0a10000000000000103" + }, "language": "de", "type": "footer", "elements": [ - { "name": "Startseite", "page": "/" }, - { "name": "Über uns", "page": "/ueber-uns" }, - { "name": "Kontakt", "page": "/kontakt" }, - { "name": "GitHub", "external": true, "externalUrl": "https://github.com" } + { + "name": "Startseite", + "page": "6821c0a10000000000000001" + }, + { + "name": "Über uns", + "page": "6821c0a10000000000000003" + }, + { + "name": "Kontakt", + "page": "6821c0a10000000000000005" + }, + { + "name": "GitHub", + "external": true, + "externalUrl": "https://github.com" + } ] }, { - "id": "footer-en", - "_id": "footer-en", + "_id": { + "$oid": "6821c0a10000000000000104" + }, "language": "en", "type": "footer", "elements": [ - { "name": "Home", "page": "/" }, - { "name": "About", "page": "/about" }, - { "name": "Contact", "page": "/contact" }, - { "name": "GitHub", "external": true, "externalUrl": "https://github.com" } + { + "name": "Home", + "page": "6821c0a10000000000000002" + }, + { + "name": "About", + "page": "6821c0a10000000000000004" + }, + { + "name": "Contact", + "page": "6821c0a10000000000000006" + }, + { + "name": "GitHub", + "external": true, + "externalUrl": "https://github.com" + } ] } -] +] \ No newline at end of file diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9e65421..f4011af 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -22,6 +22,9 @@ stripLanguageFromPath, } from "./lib/i18n" + const CONTENT_MEDIA_LOOKUP = ["blocks.heroImage.image:medialib", "blocks.image:medialib"].join(",") + const NAVIGATION_CONTENT_LOOKUP = "elements.page:content" + let { url = "" }: { url?: string } = $props() initScrollRestoration() @@ -50,6 +53,48 @@ } }) + function focusMainContent(updateHash = false) { + if (typeof window === "undefined") { + return + } + + const mainContent = document.getElementById("main-content") + if (!(mainContent instanceof HTMLElement)) { + return + } + + if (updateHash) { + window.history.pushState(window.history.state, "", "#main-content") + } + + window.requestAnimationFrame(() => { + mainContent.scrollIntoView({ block: "start" }) + mainContent.focus() + }) + } + + function handleSkipToMainContent() { + focusMainContent(true) + } + + $effect(() => { + if (typeof window === "undefined") { + return + } + + const handleHashChange = () => { + if (window.location.hash === "#main-content") { + focusMainContent() + } + } + + window.addEventListener("hashchange", handleHashChange) + + return () => { + window.removeEventListener("hashchange", handleHashChange) + } + }) + // metrics let oldPath = $state("") $effect(() => { @@ -74,6 +119,16 @@ let contentEntry = $state(null) let headerNav = $state(null) let footerNav = $state(null) + + function resolveNavigationHref(item: NavigationElement): string { + const resolvedPagePath = item._lookup?.page?.path || (item.page?.startsWith("/") ? item.page : "/") + const localized = localizedPath(resolvedPagePath || "/") + + if (!item.hash) return localized + + const normalizedHash = item.hash.startsWith("#") ? item.hash : `#${item.hash}` + return `${localized}${normalizedHash}` + } let loading = $state(true) let notFound = $state(false) @@ -103,18 +158,42 @@ try { // Load navigation const [headerEntries, footerEntries] = await Promise.all([ - getCachedEntries<"navigation">("navigation", { type: "header", language: lang }), - getCachedEntries<"navigation">("navigation", { type: "footer", language: lang }), + getCachedEntries<"navigation">( + "navigation", + { type: "header", language: lang }, + "sort", + undefined, + undefined, + undefined, + { lookup: NAVIGATION_CONTENT_LOOKUP } + ), + getCachedEntries<"navigation">( + "navigation", + { type: "footer", language: lang }, + "sort", + undefined, + undefined, + undefined, + { lookup: NAVIGATION_CONTENT_LOOKUP } + ), ]) headerNav = headerEntries[0] || null footerNav = footerEntries[0] || null // Load content for current path - const contentEntries = await getCachedEntries<"content">("content", { - lang, - path: routePath, - active: true, - }) + const contentEntries = await getCachedEntries<"content">( + "content", + { + lang, + path: routePath, + active: true, + }, + "sort", + undefined, + undefined, + undefined, + { lookup: CONTENT_MEDIA_LOOKUP } + ) if (contentEntries.length > 0) { contentEntry = contentEntries[0] @@ -157,6 +236,9 @@ {#if contentEntry?.meta?.description} {/if} + {#if contentEntry?.meta?.keywords?.length} + + {/if}
@@ -168,6 +250,15 @@ ? 'bg-white/80 backdrop-blur-lg shadow-sm' : 'bg-transparent'}" > + +
{item.name} @@ -273,7 +364,7 @@ -
+
{#if loading}
@@ -319,7 +410,7 @@
{:else} {item.name} diff --git a/frontend/src/admin.ts b/frontend/src/admin.ts index 69acad0..f6193db 100644 --- a/frontend/src/admin.ts +++ b/frontend/src/admin.ts @@ -1,4 +1,40 @@ -import type { SvelteComponent } from "svelte" +import { mount, unmount, type Component, type SvelteComponent } from "svelte" +import BlockRenderer from "./blocks/BlockRenderer.svelte" + +const previewCssUrl = new URL("./index.css", import.meta.url).toString() + +type BlockRenderContext = { + namespace?: string + apiBase?: string + projectBase?: string +} + +type BlockHandle = { + update(row: Record, context?: BlockRenderContext): void + destroy(): void +} + +type BlockDefinition = { + render(container: HTMLElement | ShadowRoot, row: Record, context?: BlockRenderContext): BlockHandle + css?: string[] + previewStyles?: Record + label?: string + icon?: string + color?: string +} + +type BlockPresentation = { + label: string + icon: string + color: string +} + +function getAdminPreviewProps(props?: { [key: string]: any }) { + return { + isAdminPreview: true, + ...(props || {}), + } +} function getRenderedElement( component: typeof SvelteComponent, @@ -33,13 +69,71 @@ function getRenderedElement( new component({ target: target, - props: options?.props, + props: getAdminPreviewProps(options?.props), }) return el } +function createContentBlockDefinition(presentation: BlockPresentation): BlockDefinition { + return { + css: [previewCssUrl], + label: presentation.label, + icon: presentation.icon, + color: presentation.color, + previewStyles: { + "background-color": "white", + position: "relative", + }, + render(container, row, context) { + const target = document.createElement("div") + target.dataset.adminPreview = "true" + container.appendChild(target) + + let mountedComponent = mount(BlockRenderer as Component, { + target, + props: { + blocks: [row as ContentBlockEntry], + isAdminPreview: true, + }, + }) + + return { + update(nextRow) { + unmount(mountedComponent) + target.innerHTML = "" + mountedComponent = mount(BlockRenderer as Component, { + target, + props: { + blocks: [nextRow as ContentBlockEntry], + isAdminPreview: true, + }, + }) + }, + destroy() { + unmount(mountedComponent) + target.remove() + }, + } + }, + } +} + +const blockRegistry = { + hero: createContentBlockDefinition({ label: "Hero", icon: "image", color: "#1d4ed8" }), + features: createContentBlockDefinition({ label: "Features", icon: "view_quilt", color: "#0f766e" }), + richtext: createContentBlockDefinition({ label: "Richtext", icon: "article", color: "#7c3aed" }), + accordion: createContentBlockDefinition({ label: "Accordion", icon: "expand", color: "#b45309" }), + "contact-form": createContentBlockDefinition({ + label: "Contact Form", + icon: "mail", + color: "#be185d", + }), +} + export { + getAdminPreviewProps, getRenderedElement, + blockRegistry, // pass also required svelte components here } diff --git a/frontend/src/blocks/FeatureIcon.svelte b/frontend/src/blocks/FeatureIcon.svelte new file mode 100644 index 0000000..7df5eb1 --- /dev/null +++ b/frontend/src/blocks/FeatureIcon.svelte @@ -0,0 +1,99 @@ + + +{#if icon === "palette"} + +{:else if icon === "database"} + +{:else if icon === "globe"} + +{:else if icon === "monitor"} + +{:else if icon === "flask"} + +{:else} + +{/if} diff --git a/frontend/src/blocks/FeaturesBlock.svelte b/frontend/src/blocks/FeaturesBlock.svelte index efad3dd..088af2e 100644 --- a/frontend/src/blocks/FeaturesBlock.svelte +++ b/frontend/src/blocks/FeaturesBlock.svelte @@ -1,5 +1,6 @@ @@ -17,9 +23,12 @@ {#if hasImage}
- {#if block.heroImage?.externalUrl} - - {/if} +
{:else} diff --git a/frontend/src/blocks/RichtextBlock.svelte b/frontend/src/blocks/RichtextBlock.svelte index 06ebfe2..b559453 100644 --- a/frontend/src/blocks/RichtextBlock.svelte +++ b/frontend/src/blocks/RichtextBlock.svelte @@ -1,8 +1,11 @@
@@ -54,12 +57,14 @@
- {block.headline + {#if block.image || resolvedImage} + + {/if}
import("./locales/de.json")) register("en", () => import("./locales/en.json")) +let isI18nInitialized = false +let syncSubscriptionsInitialized = false + +function ensureI18nInitialized(initialLocale: SupportedLanguage = DEFAULT_LANGUAGE): void { + if (isI18nInitialized) { + return + } + + init({ + fallbackLocale: DEFAULT_LANGUAGE, + initialLocale, + }) + + isI18nInitialized = true +} + +function ensureLocaleSync(): void { + if (syncSubscriptionsInitialized) { + return + } + + // Keep svelte-i18n locale and selectedLanguage store in sync + locale.subscribe((newLocale) => { + if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) { + selectedLanguage.set(newLocale as SupportedLanguage) + } + }) + + selectedLanguage.subscribe((newLang) => { + const currentLocale = get(locale) + if (newLang && newLang !== currentLocale) { + locale.set(newLang) + } + }) + + syncSubscriptionsInitialized = true +} + +ensureI18nInitialized() +ensureLocaleSync() + /** * Determine the initial locale from URL, browser, or fallback. */ @@ -50,26 +91,11 @@ function getInitialLocale(url?: string): SupportedLanguage { export async function setupI18n(url?: string): Promise { const initialLocale = getInitialLocale(url) - init({ - fallbackLocale: DEFAULT_LANGUAGE, - initialLocale, - }) + ensureI18nInitialized(initialLocale) + ensureLocaleSync() selectedLanguage.set(initialLocale) - - // Keep svelte-i18n locale and selectedLanguage store in sync - locale.subscribe((newLocale) => { - if (newLocale && SUPPORTED_LANGUAGES.includes(newLocale as SupportedLanguage)) { - selectedLanguage.set(newLocale as SupportedLanguage) - } - }) - - selectedLanguage.subscribe((newLang) => { - const currentLocale = get(locale) - if (newLang && newLang !== currentLocale) { - locale.set(newLang) - } - }) + locale.set(initialLocale) await waitLocale() } diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index f90e915..f643c90 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -50,6 +50,7 @@ }, "welcome": "Willkommen", "language": "Sprache", + "skipToMainContent": "Zum Hauptinhalt springen", "scrollToTop": "Nach oben", "loading": "Laden…" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index b09d3e3..f2dfe40 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -50,6 +50,7 @@ }, "welcome": "Welcome", "language": "Language", + "skipToMainContent": "Skip to main content", "scrollToTop": "Scroll to top", "loading": "Loading…" } \ No newline at end of file diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index 38534cd..9aacaba 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -14,11 +14,50 @@ // Add new collections here as needed. // --------------------------------------------------------------------------- import contentData from "../../mocking/content.json" +import medialibData from "../../mocking/medialib.json" import navigationData from "../../mocking/navigation.json" +type EJsonObjectId = { + $oid: string +} + const mockRegistry: Record[]> = { - content: contentData as Record[], - navigation: navigationData as Record[], + content: normalizeMockCollection(contentData as Record[]), + medialib: normalizeMockCollection(medialibData as Record[]), + navigation: normalizeMockCollection(navigationData as Record[]), +} + +function isEJsonObjectId(value: unknown): value is EJsonObjectId { + return !!value && typeof value === "object" && "$oid" in value && typeof (value as EJsonObjectId).$oid === "string" +} + +function normalizeMockCollection(entries: Record[]): Record[] { + return entries.map((entry) => normalizeMockValue(entry)) +} + +function normalizeMockValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => normalizeMockValue(item)) as T + } + + if (!value || typeof value !== "object") { + return value + } + + const normalized: Record = {} + for (const [key, nestedValue] of Object.entries(value as Record)) { + normalized[key] = normalizeMockValue(nestedValue) + } + + if (isEJsonObjectId(normalized._id)) { + normalized._id = normalized._id.$oid + } + + if (typeof normalized._id === "string" && normalized.id === undefined) { + normalized.id = normalized._id + } + + return normalized as T } // --------------------------------------------------------------------------- @@ -49,9 +88,10 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u // --- Single-item retrieval --- if (itemId) { const item = sourceData.find((e) => e.id === itemId || e._id === itemId) + const resultItem = item ? applyLookups(cloneEntry(item), options) : null return { - data: item ?? null, - count: item ? 1 : 0, + data: resultItem, + count: resultItem ? 1 : 0, buildTime: null, } } @@ -79,6 +119,8 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u results = results.slice(0, options.limit) } + results = results.map((entry) => applyLookups(cloneEntry(entry), options)) + // Projection if (options?.projection) { results = applyProjection(results, options.projection) @@ -184,6 +226,81 @@ function getNestedValue(obj: Record, path: string): unknown { }, obj) } +function cloneEntry(entry: T): T { + return JSON.parse(JSON.stringify(entry)) as T +} + +function applyLookups(entry: Record, options?: ApiOptions): Record { + const lookupSpecs = parseLookupSpecs(options) + if (!lookupSpecs.length) return entry + + for (const spec of lookupSpecs) { + const [fieldPath, collection] = spec.split(":") + if (!fieldPath || !collection) continue + + const lookupSource = mockRegistry[collection] + if (!lookupSource) continue + + applyLookupAtPath(entry, fieldPath.split("."), lookupSource) + } + + return entry +} + +function parseLookupSpecs(options?: ApiOptions): string[] { + const rawLookup = [options?.lookup, options?.params?.lookup] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean) + + return Array.from(new Set(rawLookup)) +} + +function applyLookupAtPath( + current: Record, + pathSegments: string[], + lookupSource: Record[] +): void { + const [segment, ...rest] = pathSegments + if (!segment) return + + const value = current[segment] + + if (rest.length === 0) { + current._lookup = (current._lookup as Record | undefined) || {} + ;(current._lookup as Record)[segment] = resolveLookupValue(value, lookupSource) + return + } + + if (Array.isArray(value)) { + value.forEach((item) => { + if (item && typeof item === "object") { + applyLookupAtPath(item as Record, rest, lookupSource) + } + }) + return + } + + if (value && typeof value === "object") { + applyLookupAtPath(value as Record, rest, lookupSource) + } +} + +function resolveLookupValue(value: unknown, lookupSource: Record[]): unknown { + if (Array.isArray(value)) { + return value.map((entryId) => resolveLookupById(entryId, lookupSource)) + } + + return resolveLookupById(value, lookupSource) +} + +function resolveLookupById(value: unknown, lookupSource: Record[]): Record | null { + if (typeof value !== "string") return null + + return lookupSource.find((entry) => entry.id === value || entry._id === value) || null +} + // --------------------------------------------------------------------------- // Sort // --------------------------------------------------------------------------- @@ -244,7 +361,8 @@ function applyProjection(data: Record[], projectionStr: string) if (field in entry) result[field] = entry[field] } // Always include id/_id - if (entry.id !== undefined) result.id = entry.id + if (typeof entry.id === "string") result.id = entry.id + else if (typeof entry._id === "string") result.id = entry._id if (entry._id !== undefined) result._id = entry._id return result } diff --git a/frontend/src/widgets/MedialibImage.svelte b/frontend/src/widgets/MedialibImage.svelte index fe1d11f..2844060 100644 --- a/frontend/src/widgets/MedialibImage.svelte +++ b/frontend/src/widgets/MedialibImage.svelte @@ -1,47 +1,10 @@ -{#if id} - {#if loading} - {#if !noPlaceholder} - loading - {/if} - {:else if entry && fileSrc} +{#if effectiveId} + {#if entry && fileSrc} {#if showCaption && caption}
{entry.alt - not found + not found {/if} {/if} diff --git a/package.json b/package.json index 1eaee90..b473188 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "start:mock": "MOCK=1 node scripts/esbuild-wrapper.js start", "start:ssr": "SSR=1 node scripts/esbuild-wrapper.js start", "build": "node scripts/esbuild-wrapper.js build", - "build:admin": "node scripts/esbuild-wrapper.js build esbuild.config.admin.js", "build:server": "node scripts/esbuild-wrapper.js build esbuild.config.server.js && babel --config-file ./babel.config.server.json _temp/app.server.js -o _temp/app.server.babeled.js && esbuild _temp/app.server.babeled.js --outfile=api/hooks/lib/app.server.js --bundle --sourcemap --platform=node --banner:js='// @ts-nocheck'", "test": "playwright test", "test:e2e": "playwright test tests/e2e", diff --git a/scripts/esbuild-wrapper.js b/scripts/esbuild-wrapper.js index cd5f997..402033a 100644 --- a/scripts/esbuild-wrapper.js +++ b/scripts/esbuild-wrapper.js @@ -16,10 +16,20 @@ function log(str, clear) { let buildResults let ctx +function cleanChunks() { + const outdir = config.options.outdir + if (!outdir) return + const chunksDir = path.join(outdir, "chunks") + if (fs.existsSync(chunksDir)) { + fs.rmSync(chunksDir, { recursive: true }) + } +} + async function build(catchError) { if (config.writeBuildInfo) { config.writeBuildInfo() } + cleanChunks() if (!ctx) ctx = await esbuild.context(config.options) log((buildResults ? "re" : "") + "building...") const timerStart = Date.now() @@ -76,6 +86,7 @@ switch (process.argv?.length > 2 ? process.argv[2] : "build") { if (config.writeBuildInfo) { config.writeBuildInfo() } + cleanChunks() esbuild.build(config.options).then(function (buildResults) { if (config.options.metafile) { fs.writeFileSync( diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts index 5d58a1a..ceec541 100644 --- a/tests/e2e/home.spec.ts +++ b/tests/e2e/home.spec.ts @@ -36,4 +36,20 @@ test.describe("Home Page", () => { expect(page.url()).toContain("/en") } }) + + test("should allow skipping directly to main content", async ({ page }) => { + await page.goto("/de/") + await waitForSpaReady(page) + + await page.keyboard.press("Tab") + + const skipLink = page.getByRole("button", { name: "Zum Hauptinhalt springen" }) + await expect(skipLink).toBeFocused() + + await page.keyboard.press("Enter") + + const mainContent = page.locator("main#main-content") + await expect(mainContent).toBeFocused() + await expect(page).toHaveURL(/#main-content$/) + }) }) diff --git a/types/global.d.ts b/types/global.d.ts index 496ae69..05778c7 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -50,18 +50,39 @@ interface FileField { size: number } +type LocalizedText = { + [lang: string]: string | undefined +} + interface MedialibEntry { id?: string + _id?: string file?: { src?: string type?: string } - alt?: string + title?: string + alt?: string | LocalizedText + description?: string [key: string]: unknown } +interface LookupContainer { + _lookup?: Record +} + +interface ContentHeroImage extends LookupContainer { + image?: string +} + +interface ContentFeatureBoxEntry { + icon?: "lightning" | "palette" | "database" | "globe" | "monitor" | "flask" + title?: string + text?: string +} + /** Pagebuilder: Content Block Entry */ -interface ContentBlockEntry { +interface ContentBlockEntry extends LookupContainer { hide?: boolean headline?: string headlineH1?: boolean @@ -69,10 +90,6 @@ interface ContentBlockEntry { tagline?: string anchorId?: string containerWidth?: "" | "wide" | "full" - background?: { - color?: string - image?: string - } padding?: { top?: string bottom?: string @@ -83,41 +100,25 @@ interface ContentBlockEntry { buttonLink?: string buttonTarget?: string } - heroImage?: { - image?: string - /** External image URL (e.g. Unsplash) — used when no medialib ID is available */ - externalUrl?: string - } + heroImage?: ContentHeroImage + featureBoxes?: ContentFeatureBoxEntry[] // richtext fields text?: string imagePosition?: "none" | "left" | "right" - imageRounded?: string image?: string - /** External image URL for richtext/generic blocks (e.g. Unsplash) */ - externalImageUrl?: string // accordion fields accordionItems?: { question?: string answer?: string open?: boolean }[] - // imageGallery fields - imageGallery?: { - images?: { - image?: string - caption?: string - showCaption?: boolean - }[] - } - // richtext caption fields - showImageCaption?: boolean - imageCaption?: string } /** Content Entry from the CMS */ interface ContentEntry { id?: string _id?: string + _lookup?: Record active?: boolean publication?: { from?: string | Date @@ -129,13 +130,12 @@ interface ContentEntry { name?: string path?: string alternativePaths?: { path?: string }[] - thumbnail?: string teaserText?: string blocks?: ContentBlockEntry[] meta?: { title?: string description?: string - keywords?: string + keywords?: string[] } } @@ -146,6 +146,9 @@ interface NavigationElement { page?: string hash?: string externalUrl?: string + _lookup?: { + page?: ContentEntry | null + } } /** Navigation entry from the CMS */ diff --git a/video-tours/tours/demo-showcase.tour.ts b/video-tours/tours/demo-showcase.tour.ts index 10b9a4e..bb18211 100644 --- a/video-tours/tours/demo-showcase.tour.ts +++ b/video-tours/tours/demo-showcase.tour.ts @@ -5,7 +5,7 @@ import { moveThenClick, moveThenType, smoothScroll } from "../helpers" * Video tour: Full demo showcase * * Walks through all demo pages, demonstrating: - * 1. Homepage — hero, features, richtext, FAQ accordion + * 1. Homepage — hero, richtext sections, FAQ accordion * 2. About page — content blocks * 3. Contact page — form interaction * 4. Language switching (EN ↔ DE with route translation) @@ -20,11 +20,11 @@ tour("Demo Showcase", async ({ tourPage: page }) => { await page.goto("/de/") await page.waitForTimeout(2500) - // Scroll down through hero → features + // Scroll down through hero → first richtext section await smoothScroll(page, 500) await page.waitForTimeout(2000) - // Scroll through features + // Scroll through the first richtext section await smoothScroll(page, 1200) await page.waitForTimeout(2500)