From a9a13a6b5b883992fb6fe16984b4c013a59e8df7 Mon Sep 17 00:00:00 2001 From: Sebastian Frank Date: Sat, 7 Mar 2026 16:16:19 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20admin-ui-config,=20co?= =?UTF-8?q?ntent-authoring,=20and=20frontend-architecture=20skills=20docum?= =?UTF-8?q?entation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `admin-ui-config` skill for configuring admin UI for collections. - Added `content-authoring` skill detailing page and block creation in the CMS. - Included `frontend-architecture` skill explaining custom SPA routing and state management. - Updated `AGENTS.md` to reference new skills and provide infrastructure prerequisites. - Enhanced `frontend/AGENTS.md` with routing details and SPA navigation information. --- .agents/skills/admin-ui-config/SKILL.md | 524 ++++++++++++++++++ .agents/skills/content-authoring/SKILL.md | 325 +++++++++++ .agents/skills/frontend-architecture/SKILL.md | 360 ++++++++++++ AGENTS.md | 34 ++ frontend/AGENTS.md | 11 + 5 files changed, 1254 insertions(+) create mode 100644 .agents/skills/admin-ui-config/SKILL.md create mode 100644 .agents/skills/content-authoring/SKILL.md create mode 100644 .agents/skills/frontend-architecture/SKILL.md diff --git a/.agents/skills/admin-ui-config/SKILL.md b/.agents/skills/admin-ui-config/SKILL.md new file mode 100644 index 0000000..edacd2f --- /dev/null +++ b/.agents/skills/admin-ui-config/SKILL.md @@ -0,0 +1,524 @@ +--- +name: admin-ui-config +description: Configure the admin UI for collections — meta labels, views (table/list/cards), field widgets, inputProps, fieldLists, sidebar layout, choices, foreign references, and image handling. Use when setting up or customizing collection admin views. +--- + +# admin-ui-config + +## When to use this skill + +Use this skill when: + +- Configuring how a collection appears in the tibi-admin UI +- Setting up table/list/card views for a collection +- Configuring field widgets (dropdowns, media pickers, richtext, etc.) +- Organizing fields into sidebar groups or sections +- Setting up foreign key references between collections +- Customizing the admin module (`frontend/src/admin.ts`) + +## Reference source + +The canonical type definitions are in `tibi-admin-nova/types/admin.d.ts` (1296 lines). Always consult this file for the full API. This skill provides a practical summary. + +--- + +## Collection meta configuration + +The `meta` key in a collection YAML controls how the collection appears in the admin sidebar and list views. + +```yaml +name: mycollection +meta: + label: { de: "Produkte", en: "Products" } # Sidebar label (i18n) + muiIcon: shopping_cart # Material UI icon name + group: shop # Group in admin sidebar + singleton: false # true = only one entry allowed + hideInNavigation: false # true = don't show in sidebar + defaultSort: "-insertTime" # Default sort (prefix - = descending) + rowIdentTpl: { twig: "{{ name }} ({{ price }})" } # Row display template +``` + +### Row identification + +`rowIdentTpl` uses Twig syntax with field names. Used in admin list to identify entries: + +```yaml +rowIdentTpl: { twig: "{{ name }}" } # Simple +rowIdentTpl: { twig: "{{ type }} — {{ language }}" } # Combined +``` + +--- + +## Views: table, simpleList, cardList + +The `views` array defines how entries are displayed in the admin list. Multiple views can coexist (e.g. table for desktop, simpleList for mobile). + +### Table view + +```yaml +meta: + views: + - type: table + columns: + - name # Simple: field name as column + - source: lang # With filter + filter: true + - source: active # Boolean column with filter + filter: true + - source: price # Custom label + label: { de: "Preis", en: "Price" } + - source: insertTime # Date field + width: 160 +``` + +### Simple list view (mobile) + +```yaml +meta: + views: + - type: simpleList + mediaQuery: "(max-width: 600px)" # Show only on small screens + primaryText: name + secondaryText: lang + tertiaryText: path + image: thumbnail # Optional: show image thumbnail +``` + +### Card list view + +```yaml +meta: + views: + - type: cardList + fields: + - source: name + label: Name + - source: price + label: Preis + widget: currency +``` + +--- + +## Field configuration + +Each field in the `fields` array can have a `meta` section controlling its admin UI behavior. + +### Basic field with meta + +```yaml +fields: + - name: name + type: string + meta: + label: { de: "Name", en: "Name" } + helperText: { de: "Anzeigename", en: "Display name" } + position: main # "main" (default) or "sidebar" +``` + +### Field types + +| YAML `type` | Admin widget (default) | Notes | +| ----------- | ---------------------- | --------------------------------------------- | +| `string` | Text input | Use `inputProps.multiline: true` for textarea | +| `number` | Number input | | +| `boolean` | Toggle/checkbox | | +| `date` | Date picker | | +| `object` | Nested field group | Requires `subFields` | +| `object[]` | Repeatable group | Requires `subFields`, drag-to-reorder | +| `string[]` | Tag input | | +| `file` | File upload | | +| `file[]` | Multi-file upload | | + +### inputProps — widget customization + +`inputProps` passes props directly to the field widget: + +```yaml +# Multiline text (textarea) +- name: description + type: string + meta: + label: { de: "Beschreibung", en: "Description" } + inputProps: + multiline: true + rows: 5 + +# Number with min/max +- name: price + type: number + meta: + inputProps: + min: 0 + max: 99999 + step: 0.01 + +# Placeholder text +- name: email + type: string + meta: + inputProps: + placeholder: "name@example.com" +``` + +### Widget override + +Override the default widget with `meta.widget`: + +```yaml +- name: content + type: string + meta: + widget: richtext # Rich text editor (HTML) + +- name: color + type: string + meta: + widget: color # Color picker + +- name: image + type: string + meta: + widget: medialib # Media library picker +``` + +Common widget types: `text` (default), `richtext`, `color`, `medialib`, `code`, `markdown`, `password`, `hidden`. + +### Choices — dropdowns/selects + +Static choices: + +```yaml +- name: type + type: string + meta: + label: { de: "Typ", en: "Type" } + choices: + - id: page + name: { de: "Seite", en: "Page" } + - id: blog + name: { de: "Blog", en: "Blog" } + - id: product + name: { de: "Produkt", en: "Product" } +``` + +Dynamic choices from API: + +```yaml +- name: category + type: string + meta: + choices: + endpoint: categories # Collection name + mapping: + id: _id + name: name +``` + +### Foreign references + +Link to entries in another collection: + +```yaml +- name: author + type: string + meta: + label: { de: "Autor", en: "Author" } + foreign: + collection: users + id: _id + sort: name + projection: name,email + render: { twig: "{{ name }} <{{ email }}>" } + autoFill: # Auto-fill other fields on selection + - source: email + target: authorEmail +``` + +### Image fields + +```yaml +- name: image + type: file + meta: + widget: medialib + downscale: # Auto-resize on upload + maxWidth: 1920 + maxHeight: 1080 + quality: 0.85 + imageEditor: true # Enable crop/rotate editor +``` + +--- + +## Layout: position, sections, sidebar + +### Sidebar placement + +```yaml +- name: active + type: boolean + meta: + position: sidebar # Moves field to sidebar + +- name: publishDate + type: date + meta: + position: "sidebar:Veröffentlichung" # Sidebar with group header +``` + +### Sidebar groups (ordered) + +Define sidebar group order in collection meta: + +```yaml +meta: + sidebar: + - Veröffentlichung + - SEO + - Einstellungen +``` + +### Sections in main area + +```yaml +- name: seoTitle + type: string + meta: + section: SEO # Groups fields under a section header + +- name: seoDescription + type: string + meta: + section: SEO +``` + +### Grid layout (columns) + +Use `containerProps` for multi-column layout: + +```yaml +- name: firstName + type: string + meta: + containerProps: + layout: + size: col-6 # Half width (12-column grid) + +- name: lastName + type: string + meta: + containerProps: + layout: + size: col-6 +``` + +--- + +## Nested objects and arrays + +### Object (nested group) + +```yaml +- name: address + type: object + meta: + label: { de: "Adresse", en: "Address" } + subFields: + - name: street + type: string + - name: city + type: string + - name: zip + type: string +``` + +### Object array (repeatable blocks) + +```yaml +- name: blocks + type: object[] + meta: + label: { de: "Inhaltsblöcke", en: "Content Blocks" } + preview: { eval: "item.type + ': ' + (item.headline || '')" } + subFields: + - name: type + type: string + meta: + choices: + - id: hero + name: Hero + - id: richtext + name: Richtext + - name: headline + type: string + - name: hide + type: boolean +``` + +The `preview` eval determines what's shown in the collapsed state of each array item. + +### Drill-down + +For complex nested objects, use `drillDown` to render them as a sub-page: + +```yaml +- name: variants + type: object[] + meta: + drillDown: true # Opens as sub-page instead of inline +``` + +--- + +## Admin module (frontend/src/admin.ts) + +The `admin.ts` file exports custom Svelte components for injection into the tibi-admin UI. Components are rendered inside Shadow DOM to isolate styles. + +```typescript +import type { SvelteComponent } from "svelte" + +function getRenderedElement( + component: typeof SvelteComponent, + options?: { props: { [key: string]: any }; addCss?: string[] }, + nestedElements?: { tagName: string; className?: string }[] +) { + // Creates a Shadow DOM container, mounts the Svelte component inside + // addCss: CSS files to inject into Shadow DOM + // nestedElements: wrapper elements inside Shadow DOM +} + +export { getRenderedElement } +``` + +Build with `yarn build:admin`. The output 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. + +--- + +## Complete collection example + +```yaml +name: products +meta: + label: { de: "Produkte", en: "Products" } + muiIcon: inventory_2 + group: shop + defaultSort: "-insertTime" + rowIdentTpl: { twig: "{{ name }} ({{ sku }})" } + sidebar: + - Veröffentlichung + - SEO + + views: + - type: simpleList + mediaQuery: "(max-width: 600px)" + primaryText: name + secondaryText: sku + image: image + - type: table + columns: + - name + - sku + - source: price + label: { de: "Preis", en: "Price" } + - source: active + filter: true + - source: category + filter: true + +permissions: + public: + methods: + get: true + user: + methods: + get: true + post: true + put: true + delete: true + +hooks: + beforeRead: | + !include hooks/filter_public.js + afterWrite: | + !include hooks/clear_cache.js + +fields: + - name: active + type: boolean + meta: + label: { de: "Aktiv", en: "Active" } + position: "sidebar:Veröffentlichung" + - name: name + type: string + meta: + label: { de: "Name", en: "Name" } + - name: sku + type: string + meta: + label: { de: "Artikelnummer", en: "SKU" } + containerProps: + layout: + size: col-6 + - name: price + type: number + meta: + label: { de: "Preis", en: "Price" } + inputProps: + min: 0 + step: 0.01 + containerProps: + layout: + size: col-6 + - name: category + type: string + meta: + label: { de: "Kategorie", en: "Category" } + choices: + - id: electronics + name: { de: "Elektronik", en: "Electronics" } + - id: clothing + name: { de: "Kleidung", en: "Clothing" } + - name: description + type: string + meta: + label: { de: "Beschreibung", en: "Description" } + inputProps: + multiline: true + rows: 4 + - name: image + type: file + meta: + label: { de: "Produktbild", en: "Product Image" } + widget: medialib + downscale: + maxWidth: 1200 + quality: 0.85 + - name: seoTitle + type: string + meta: + label: { de: "SEO Titel", en: "SEO Title" } + position: "sidebar:SEO" + - name: seoDescription + type: string + meta: + label: { de: "SEO Beschreibung", en: "SEO Description" } + position: "sidebar:SEO" + inputProps: + multiline: true + rows: 3 +``` + +--- + +## Common pitfalls + +- **`meta.label` is i18n** — Always provide `{ de: "...", en: "..." }` objects, not plain strings. +- **`views` order matters** — First matching view (by `mediaQuery`) is shown. Put mobile views (with `mediaQuery`) before desktop views (without). +- **`choices.id` must match stored value** — The `id` in choices is what gets saved to the database. +- **`inputProps` depends on widget** — Not all props work with all widgets. Check tibi-admin-nova source if unsure. +- **`position: sidebar` without group** — Fields go to an ungrouped area. Use `position: "sidebar:GroupName"` for grouping. +- **`type: object[]` needs `subFields`** — Forgetting `subFields` renders an empty repeater. +- **hooks path** — Hook includes are relative to `api/` directory: `!include hooks/myfile.js`. diff --git a/.agents/skills/content-authoring/SKILL.md b/.agents/skills/content-authoring/SKILL.md new file mode 100644 index 0000000..05b6c1c --- /dev/null +++ b/.agents/skills/content-authoring/SKILL.md @@ -0,0 +1,325 @@ +--- +name: content-authoring +description: Add new pages, content blocks, and collections to a tibi project. Covers the content-based routing model, block registration in BlockRenderer, collection YAML authoring, and TypeScript type definitions. Use when creating new pages, block types, or collections. +--- + +# content-authoring + +## When to use this skill + +Use this skill when: + +- Adding a new page to the website +- Creating a new content block type (e.g. testimonials, pricing table, gallery) +- Adding a new collection to the CMS (e.g. products, events, team members) +- Understanding how content is structured and rendered + +## Key concept: content-based routing + +This project does **NOT** use file-based routing (no SvelteKit router). Instead: + +1. Pages are **CMS entries** in the `content` collection with a `path` field. +2. `App.svelte` reacts to URL changes → calls `getCachedEntries("content", { lang, path, active: true })`. +3. The matching `ContentEntry.blocks[]` array is passed to `BlockRenderer.svelte`. +4. Each block has a `type` field that maps to a Svelte component. + +**Implication:** To add a new page, you create a content entry (via Admin UI or API) — no new Svelte file or route config is needed. + +--- + +## Adding a new page + +### Option A: Via Admin UI (preferred for content editors) + +1. Open the tibi-admin at `CODING_URL/_/admin/`. +2. Navigate to **Inhalte** (Content) collection. +3. Click **New** and fill in: + - `name`: Display name (e.g. "Über uns") + - `path`: URL path without language prefix (e.g. `/ueber-uns`) + - `lang`: Language code (e.g. `de`) + - `active`: `true` + - `translationKey`: Shared key for cross-language linking (e.g. `about`) + - `blocks`: Add content blocks (see below) + - `meta.title` / `meta.description`: SEO metadata +4. Save. The page is immediately available at `/{lang}{path}`. + +### Option B: Via API + +```sh +curl -X POST "$CODING_URL/api/content" \ + -H "Token: $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "active": true, + "lang": "de", + "name": "Über uns", + "path": "/ueber-uns", + "translationKey": "about", + "blocks": [ + { "type": "hero", "headline": "Über uns", "subline": "Unser Team" } + ], + "meta": { "title": "Über uns", "description": "Erfahre mehr über unser Team." } + }' +``` + +### Option C: Via mock data (for MOCK=1 mode) + +Add the entry to `frontend/mocking/content.json` — the mock engine supports MongoDB-style filtering. + +### Adding to navigation + +To make the page appear in the header/footer menu, edit the corresponding `navigation` entry: + +```sh +# Get existing header nav +curl "$CODING_URL/api/navigation?filter[type]=header&filter[language]=de" -H "Token: $ADMIN_TOKEN" + +# PUT to update elements array (add your page) +curl -X PUT "$CODING_URL/api/navigation/" \ + -H "Token: $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "elements": [ ...existing, { "name": "Über uns", "page": "/ueber-uns" } ] }' +``` + +### Multi-language pages + +- Create one `ContentEntry` per language with the **same `translationKey`** but different `lang` and `path`. +- The language switcher in `App.svelte` uses `currentContentEntry.translationKey` to find the equivalent page. +- Add localized route slugs to `ROUTE_TRANSLATIONS` in `frontend/src/lib/i18n.ts` if URLs should differ per language (e.g. `/ueber-uns` vs `/about`). + +--- + +## Adding a new content block type + +### Step 1: Create the Svelte component + +Create `frontend/src/blocks/MyNewBlock.svelte`: + +```svelte + + +
+
+ {#if block.headline} +

{block.headline}

+ {/if} + +
+
+``` + +**Conventions:** + +- Accept `block: ContentBlockEntry` as the single prop. +- Use `block.anchorId` for scroll anchoring. +- Respect `block.containerWidth` (`""` = default, `"wide"`, `"full"`). +- Guard browser-only code with `typeof window !== "undefined"` (SSR safety). + +### Step 2: Register in BlockRenderer + +Edit `frontend/src/blocks/BlockRenderer.svelte`: + +```svelte + +import MyNewBlock from "./MyNewBlock.svelte" + + +{:else if block.type === "my-new-block"} + +``` + +### Step 3: Extend TypeScript types (if new fields are needed) + +Edit `types/global.d.ts` — add fields to `ContentBlockEntry`: + +```typescript +interface ContentBlockEntry { + // ... existing fields ... + // my-new-block fields + myCustomField?: string + myItems?: { title: string; description: string }[] +} +``` + +### Step 4: Extend collection YAML (if new fields need admin editing) + +Edit `api/collections/content.yml` — add subFields under `blocks`: + +```yaml +- name: blocks + type: object[] + subFields: + # ... existing subFields ... + - name: myCustomField + type: string + - name: myItems + type: object[] + subFields: + - name: title + type: string + - name: description + type: string +``` + +### Step 5: Update mock data (if using MOCK=1) + +Add a block with your new type to `frontend/mocking/content.json`. + +### Step 6: Verify + +```sh +yarn validate # TypeScript check — must be warning-free +``` + +### Existing block types for reference + +| 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 | + +--- + +## Adding a new collection + +### Step 1: Create collection YAML + +Create `api/collections/mycollection.yml`. Use `content.yml` or `navigation.yml` as a template: + +```yaml +######################################################################## +# MyCollection — description of what this collection stores +######################################################################## + +name: mycollection +meta: + label: { de: "Meine Sammlung", en: "My Collection" } + muiIcon: category # Material UI icon name + rowIdentTpl: { twig: "{{ name }}" } # Row display in admin list + + views: + - type: simpleList + mediaQuery: "(max-width: 600px)" + primaryText: name + - type: table + columns: + - name + - source: active + filter: true + +permissions: + public: + methods: + get: true # Public read access + user: + methods: + get: true + post: true + put: true + delete: true + +fields: + - name: active + type: boolean + meta: + label: { de: "Aktiv", en: "Active" } + - name: name + type: string + meta: + label: { de: "Name", en: "Name" } + # Add more fields as needed +``` + +**Field types:** `string`, `number`, `boolean`, `object`, `object[]`, `string[]`, `file`, `file[]`. + +For the full schema reference: `tibi-types/schemas/api-config/collection.json`. + +### Step 2: Include in config.yml + +Edit `api/config.yml`: + +```yaml +collections: + - !include collections/content.yml + - !include collections/navigation.yml + - !include collections/ssr.yml + - !include collections/mycollection.yml # ← add this line +``` + +### Step 3: Add TypeScript types + +Edit `types/global.d.ts`: + +```typescript +interface MyCollectionEntry { + id?: string + _id?: string + active?: boolean + name?: string + // ... fields matching your YAML +} +``` + +### Step 4: Configure API layer (optional) + +If you need typed helpers, extend the `EntryTypeSwitch` in `frontend/src/lib/api.ts`: + +```typescript +type CollectionNameT = "medialib" | "content" | "mycollection" | string + +type EntryTypeSwitch = T extends "medialib" + ? MedialibEntry + : T extends "content" + ? ContentEntry + : T extends "mycollection" + ? MyCollectionEntry + : Record +``` + +### Step 5: Add hooks (optional) + +Common hook patterns: + +- **Public filter** — reuse `filter_public.js` to enforce `active: true` for unauthenticated users. +- **Before-save validation** — create `api/hooks/mycollection_validate.js`. +- **Cache invalidation** — add your collection to `api/hooks/clear_cache.js` if it affects rendered pages. + +Reference hook in YAML: + +```yaml +hooks: + beforeRead: | + !include hooks/filter_public.js + afterWrite: | + !include hooks/clear_cache.js +``` + +### Step 6: Add mock data (if using MOCK=1) + +Create `frontend/mocking/mycollection.json`: + +```json +[{ "_id": "1", "active": true, "name": "Example Entry" }] +``` + +### Step 7: Verify + +```sh +yarn validate # TypeScript check +# If Docker is running, the tibi-server auto-reloads the collection config +``` + +--- + +## Common pitfalls + +- **Path format**: Content paths do NOT include the language prefix. The path `/ueber-uns` becomes `/{lang}/ueber-uns` via the i18n layer. +- **Active flag**: Pages with `active: false` are filtered out by `filter_public.js` for public users. The admin can still see them. +- **Block `hide` field**: Blocks with `hide: true` are skipped by `BlockRenderer.svelte` — useful for draft blocks. +- **Collection YAML indentation**: YAML uses 2-space indentation. Sub-fields under `object[]` require a `subFields` key. +- **After adding a collection**: The tibi-server auto-reloads hooks on file change, but a new collection in `config.yml` may require `make docker-restart-frontend` or a full `make docker-up`. diff --git a/.agents/skills/frontend-architecture/SKILL.md b/.agents/skills/frontend-architecture/SKILL.md new file mode 100644 index 0000000..e929346 --- /dev/null +++ b/.agents/skills/frontend-architecture/SKILL.md @@ -0,0 +1,360 @@ +--- +name: frontend-architecture +description: Understand the frontend architecture — custom SPA routing, state management, Svelte 5 patterns, API layer, error handling, and i18n. Use when working on routing logic, navigation, stores, or understanding how the frontend fits together. +--- + +# frontend-architecture + +## When to use this skill + +Use this skill when: + +- Understanding or modifying the SPA routing mechanism +- Working with stores or state management +- Debugging navigation issues +- Adding new Svelte 5 reactive patterns +- Understanding the API layer and error handling +- Working with i18n / multi-language features + +--- + +## Routing: custom SPA router + +This project uses a **custom SPA router** — NOT SvelteKit, NOT file-based routing. Pages are CMS-managed content entries loaded by path. + +### Architecture + +``` +Browser URL change + ↓ +history.pushState / replaceState (proxied in store.ts) + ↓ +$location store updates (path, search, hash) + ↓ +App.svelte $effect reacts to $location.path + ↓ +loadContent(lang, routePath) → API call: getCachedEntries("content", { lang, path, active: true }) + ↓ +ContentEntry.blocks[] → BlockRenderer.svelte → individual block components +``` + +### Key files + +| File | Responsibility | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `frontend/src/lib/store.ts` | Proxies `history.pushState`/`replaceState` → updates `$location` writable store. Handles `popstate` for back/forward. | +| `frontend/src/lib/navigation.ts` | `spaNavigate(url, options)` — the programmatic navigation API. Also: `initScrollRestoration()`, `spaLink` action, hash parsing. | +| `frontend/src/lib/i18n.ts` | Language routing: `extractLanguageFromPath()`, `stripLanguageFromPath()`, `localizedPath()`, `currentLanguage` derived store, `ROUTE_TRANSLATIONS`. | +| `frontend/src/App.svelte` | Reacts to `$location.path` + `$currentLanguage`, loads content via API, passes blocks to `BlockRenderer`. | +| `frontend/src/blocks/BlockRenderer.svelte` | Maps `block.type` to Svelte components. | + +### How the location store works + +`store.ts` wraps `history.pushState` and `history.replaceState` with a `Proxy`: + +```typescript +// Simplified — see store.ts for full implementation +history.pushState = new Proxy(history.pushState, { + apply: (target, thisArg, args) => { + // Update $location store BEFORE the actual pushState + publishLocation(args[2]) // args[2] = URL + Reflect.apply(target, thisArg, args) + }, +}) +``` + +This means **any** `pushState`/`replaceState` call (from `spaNavigate`, `` clicks, or third-party code) automatically updates `$location`. + +The `popstate` event (back/forward buttons) also triggers `publishLocation()`. + +### URL structure + +``` +/{lang}/{path} + ↓ ↓ + de /ueber-uns + +Example: /de/ueber-uns → lang="de", routePath="/ueber-uns" + /en/about → lang="en", routePath="/about" + /de/ → lang="de", routePath="/" +``` + +Root `/` redirects to `/{browserLanguage}/` via `getBrowserLanguage()`. + +### Navigation API + +```typescript +import { spaNavigate } from "./lib/navigation" + +// Basic navigation (creates history entry, scrolls to top) +spaNavigate("/de/kontakt") + +// Replace current entry (no back button) +spaNavigate("/de/suche", { replace: true }) + +// Keep scroll position +spaNavigate("/de/produkte#filter=shoes", { noScroll: true }) + +// With state object +spaNavigate("/de/produkt/123", { state: { from: "search" } }) +``` + +### SPA link action + +For `` elements, use the `spaLink` action instead of `spaNavigate`: + +```svelte + + +Kontakt +Suche +``` + +The action intercepts clicks (respecting modifier keys, external links, `target="_blank"`) and calls `spaNavigate` internally. + +### BrowserSync SPA fallback + +In development, BrowserSync uses `connect-history-api-fallback` to serve `index.html` for all routes, enabling client-side routing. In production, the webserver or tibi-server handles this. + +### Localized route translations + +For translated URL slugs (e.g. `/ueber-uns` ↔ `/about`), configure `ROUTE_TRANSLATIONS` in `frontend/src/lib/i18n.ts`: + +```typescript +export const ROUTE_TRANSLATIONS: Record> = { + about: { de: "ueber-uns", en: "about" }, + contact: { de: "kontakt", en: "contact" }, + // Add more as needed +} +``` + +--- + +## State management + +The project uses **Svelte writable/derived stores** (not a centralized state library). + +### Store inventory + +| Store | File | Purpose | +| ---------------------- | ---------------------- | ----------------------------------------------------------------------------------- | +| `location` | `lib/store.ts` | Current URL state (path, search, hash, push/pop flags) | +| `mobileMenuOpen` | `lib/store.ts` | Whether mobile hamburger menu is open | +| `currentContentEntry` | `lib/store.ts` | Currently displayed page's `translationKey`, `lang`, `path` (for language switcher) | +| `previousPath` | `lib/store.ts` | Previous URL path (for conditional back buttons) | +| `apiBaseOverride` | `lib/store.ts` | Override API base URL (used by admin module) | +| `cookieConsentVisible` | `lib/store.ts` | Whether cookie consent banner is showing | +| `currentLanguage` | `lib/i18n.ts` | Derived from `$location.path` — current language code | +| `selectedLanguage` | `lib/i18n.ts` | Writable — synced with `currentLanguage` on navigation | +| `activeRequests` | `lib/requestsStore.ts` | Number of in-flight API requests (drives `LoadingBar`) | + +### Pattern: creating a new store + +```typescript +// In lib/store.ts or a dedicated file +import { writable, derived } from "svelte/store" + +// Simple writable +export const myStore = writable(initialValue) + +// Derived from other stores +export const myDerived = derived(location, ($loc) => { + return computeFromPath($loc.path) +}) +``` + +--- + +## Svelte 5 patterns used in this project + +This project uses **Svelte 5 with Runes**. Key patterns: + +### Component props + +```svelte + +``` + +### Reactive state + +```svelte + +``` + +### SSR-safe code + +```svelte + +``` + +### Svelte stores in Svelte 5 + +Stores (`writable`, `derived`) still work in Svelte 5. Use `$storeName` syntax in components: + +```svelte + +

Current path: {$location.path}

+``` + +--- + +## API layer + +### Core function: `api()` + +Located in `frontend/src/lib/api.ts`. Features: + +- **Request deduplication** — identical concurrent GETs share one promise +- **Loading indicator** — drives `activeRequests` store → `LoadingBar` +- **Build-version check** — auto-reloads page when server build is newer +- **Mock interceptor** — when `__MOCK__` is `true`, routes requests to `frontend/mocking/*.json` +- **Sentry integration** — span instrumentation (when enabled) + +### Usage patterns + +```typescript +import { api, getCachedEntries, getCachedEntry, getDBEntries, postDBEntry } from "./lib/api" + +// Cached (1h TTL, for read-heavy data) +const pages = await getCachedEntries<"content">("content", { lang: "de", active: true }) +const page = await getCachedEntry<"content">("content", { path: "/about" }) + +// Uncached +const items = await getDBEntries<"content">("content", { type: "blog" }, "sort", 10) + +// Write +const result = await postDBEntry("content", { name: "New Page", active: true }) + +// Raw API call +const { data, count } = await api("mycollection", { filter: { active: true }, limit: 20 }) +``` + +### Error handling + +```typescript +try { + const result = await api("content", { filter: { path: "/missing" } }) +} catch (err) { + // err has shape: { response: Response, data: { error: string } } + const status = (err as any)?.response?.status // e.g. 404 + const message = (err as any)?.data?.error // e.g. "Not found" + + // For user-visible errors: + import { addToast } from "./lib/toast" + addToast({ type: "error", message: "Seite nicht gefunden" }) + + // For debugging: + console.error("[MyComponent] API error:", err) +} +``` + +### Error handling guidelines + +| Scenario | Approach | +| --------------------------------- | ------------------------------------------------- | +| API error the user should see | `addToast({ type: "error", message })` | +| API error that's silently handled | `console.error(...)` for dev logging | +| Unexpected error in production | Sentry captures automatically (when enabled) | +| Missing content / 404 | Set `notFound = true` → renders `NotFound.svelte` | +| Network error / offline | Loading bar stays visible; user can retry | + +### API request flow (client-side) + +``` +Component calls api() / getCachedEntries() + ↓ +Deduplication check (skip if signal provided) + ↓ +incrementRequests() → LoadingBar appears + ↓ +__MOCK__? → mockApiRequest() (in-memory JSON filtering) + ↓ (else) +apiRequest() from api/hooks/lib/ssr (shared with SSR bundle) + ↓ +fetch("${apiBaseURL}${endpoint}?filter=...&sort=...&limit=...") + ↓ +Parse response → check X-Build-Time header + ↓ +decrementRequests() → LoadingBar disappears + ↓ +Return { data, count, buildTime } +``` + +--- + +## i18n system + +### Architecture + +- **svelte-i18n** for translation strings (`$_("key")`) +- **URL-based language routing** (`/{lang}/...`) +- **Lazy-loaded locale files** in `frontend/src/lib/i18n/locales/{lang}.json` +- **Route translations** for localized URL slugs + +### Adding a new language + +1. Create locale file: `frontend/src/lib/i18n/locales/fr.json` +2. Add to `SUPPORTED_LANGUAGES` in `frontend/src/lib/i18n.ts`: + ```typescript + export const SUPPORTED_LANGUAGES = ["de", "en", "fr"] as const + ``` +3. Add label: `export const LANGUAGE_LABELS = { ..., fr: "Français" }` +4. Add route translations for the new language in `ROUTE_TRANSLATIONS`. +5. Register in `frontend/src/lib/i18n/index.ts` (lazy loader). +6. Create content entries with `lang: "fr"` in the CMS. + +### Translation usage + +```svelte + + +

{$_("hero.title")}

+

{$_("hero.subtitle", { values: { name: "World" } })}

+``` + +--- + +## Common pitfalls + +- **Never `spaNavigate()` in SSR** — always guard with `typeof window !== "undefined"`. +- **Store subscriptions in modules** — if subscribing to stores outside components, remember to unsubscribe to prevent memory leaks. +- **API PUT returns only changed fields** — don't expect a full object back from PUT requests. +- **`_id` not `id` for filters** — API filters use MongoDB's `_id`, but response objects may have both `id` and `_id`. +- **`$location` strips trailing slashes** — `/about/` becomes `/about` (except root `/`). +- **Content cache is 1 hour** — `getCachedEntries` caches in memory for 1h. For admin previews, use `getDBEntries` (uncached). diff --git a/AGENTS.md b/AGENTS.md index fb3bfb8..004443a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,27 @@ Tibi CMS starter template — Svelte 5 SPA with esbuild, SSR via goja, and Playw - API access to collections uses the reverse proxy: `CODING_URL/api/` (e.g. `CODING_URL/api/content`). - Auth via `Token` header with ADMIN_TOKEN from `api/config.yml.env`. +## Required secrets and credentials + +| Secret | Location | Purpose | +| --- | --- | --- | +| `ADMIN_TOKEN` | `api/config.yml.env` | API admin auth + tibi-admin login | +| `SENTRY_AUTH_TOKEN` | Gitea repo secrets | Sourcemap upload to Sentry (CI only) | +| `.basic-auth-web` | project root (git-ignored) | Basic auth for BrowserSync dev server | +| `.basic-auth-code` | project root (git-ignored) | Basic auth for Code-Server / admin | +| `RSYNC_PASS` | Gitea secrets (`github.token`) | rsync deployment password (CI only) | + +**Note:** `.basic-auth-web` and `.basic-auth-code` are plain text files in `user:password` format (htpasswd). They are required by Docker Traefik labels for dev environment access. Curl and Playwright requests are let through without basic auth via Traefik label configuration. + +## Infrastructure prerequisites + +- **Code-Server environment** — This project is designed for development on a Code-Server instance at `*.code.testversion.online` with a **Traefik reverse proxy** managing HTTPS and auto-routing via Docker labels. +- **Without Code-Server/Traefik** — The Docker stack starts but the website is not reachable via hostname. Workaround: access BrowserSync directly via `http://localhost:3000` (requires exposing the port in `docker-compose-local.yml`). +- **Docker + Docker Compose** — Required for all development. Never run `yarn dev` or `yarn start` locally. +- **MongoDB** — Runs as a Docker service (`mongo`). Data persists in `tmp/mongo-data/`. +- **Production** — Deployed via rsync to an existing server running tibi-server + MongoDB. The frontend is a static SPA served by a Node.js webserver (`webserver/webserver.js`) or directly by tibi-server. +- **Staging** — Docker Compose builds a `www` container that connects to an external tibi-server at `dev-tibi-server.staging.testversion.online`. + ## Reference repositories These sibling repos in the workspace provide documentation, types, and reference implementations: @@ -100,6 +121,19 @@ Project-specific types (e.g. `Ssr`, `ApiOptions`, `ContentEntry`) live in `types - **Zero warnings policy**: After making changes, always run `yarn validate` and check the IDE problems tab. Fix all TypeScript, Svelte, and Tailwind warnings — the codebase must stay warning-free. - When Tailwind `suggestCanonicalClasses` warnings appear, always fix the **source** `.svelte`/`.ts`/`.css` file — never the compiled output. +### Architecture skills (loaded on demand) + +These skills provide deep-dive documentation. Use them when working on the respective area: + +| Skill | When to use | +| --- | --- | +| `content-authoring` | Adding new pages, content blocks, or collections | +| `frontend-architecture` | Routing, state management, Svelte 5 patterns, API layer, error handling | +| `admin-ui-config` | Configuring collection admin views, field widgets, layouts | +| `tibi-hook-authoring` | Writing or debugging server-side hooks | +| `tibi-ssr-caching` | SSR rendering and cache invalidation | +| `tibi-project-setup` | Setting up a new project from scratch | + ### Tailwind CSS 4 canonical classes This project uses Tailwind CSS 4. Always use the canonical v4 syntax: diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 679b1cf..df8774c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -10,6 +10,17 @@ Svelte 5 SPA bundled with esbuild and styled with Tailwind CSS 4. - `src/css/` — global styles and Tailwind imports. - Keep route components in a `src/routes/` folder when needed. +## Routing + +This project uses a **custom SPA router** (NOT SvelteKit, NOT file-based routing). Pages are CMS content entries loaded dynamically by URL path. + +- `lib/store.ts` proxies `history.pushState`/`replaceState` → updates `$location` store. +- `App.svelte` reacts to `$location.path` → calls `getCachedEntries("content", { lang, path })` → renders blocks. +- `lib/navigation.ts` provides `spaNavigate(url)` for programmatic navigation and `use:spaLink` action for `` elements. +- URL structure: `/{lang}/{path}` (e.g. `/de/ueber-uns`, `/en/about`). +- Root `/` auto-redirects to `/{browserLanguage}/`. +- **For full details see the `frontend-architecture` skill.** + ## Conventions - PascalCase component names; export props at the top of `