✨ feat: add admin-ui-config, content-authoring, and frontend-architecture skills documentation
- 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.
This commit is contained in:
524
.agents/skills/admin-ui-config/SKILL.md
Normal file
524
.agents/skills/admin-ui-config/SKILL.md
Normal file
@@ -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`.
|
||||||
325
.agents/skills/content-authoring/SKILL.md
Normal file
325
.agents/skills/content-authoring/SKILL.md
Normal file
@@ -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/<id>" \
|
||||||
|
-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
|
||||||
|
<script lang="ts">
|
||||||
|
let { block }: { block: ContentBlockEntry } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="py-16 sm:py-24" id={block.anchorId || undefined}>
|
||||||
|
<div class="max-w-6xl mx-auto px-6">
|
||||||
|
{#if block.headline}
|
||||||
|
<h2 class="text-3xl font-bold mb-6">{block.headline}</h2>
|
||||||
|
{/if}
|
||||||
|
<!-- Block-specific content here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
<!-- Add import at the top -->
|
||||||
|
import MyNewBlock from "./MyNewBlock.svelte"
|
||||||
|
|
||||||
|
<!-- Add case in the {#each} block -->
|
||||||
|
{:else if block.type === "my-new-block"}
|
||||||
|
<MyNewBlock {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 string> = T extends "medialib"
|
||||||
|
? MedialibEntry
|
||||||
|
: T extends "content"
|
||||||
|
? ContentEntry
|
||||||
|
: T extends "mycollection"
|
||||||
|
? MyCollectionEntry
|
||||||
|
: Record<string, unknown>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`.
|
||||||
360
.agents/skills/frontend-architecture/SKILL.md
Normal file
360
.agents/skills/frontend-architecture/SKILL.md
Normal file
@@ -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`, `<a>` 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 `<a>` elements, use the `spaLink` action instead of `spaNavigate`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { spaLink } from "../lib/navigation"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a href="/de/kontakt" use:spaLink>Kontakt</a>
|
||||||
|
<a href="/de/suche" use:spaLink={{ replace: true }}>Suche</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
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<string, Record<SupportedLanguage, string>> = {
|
||||||
|
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<MyType>(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
|
||||||
|
<script lang="ts">
|
||||||
|
// Rune syntax — replaces export let
|
||||||
|
let { block, className = "" }: { block: ContentBlockEntry; className?: string } = $props()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reactive state
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
// Local reactive state (replaces let x; with $: reactivity)
|
||||||
|
let count = $state(0)
|
||||||
|
let items = $state<Item[]>([])
|
||||||
|
|
||||||
|
// Computed/derived values (replaces $: derived = ...)
|
||||||
|
let total = $derived(items.reduce((sum, i) => sum + i.price, 0))
|
||||||
|
|
||||||
|
// Side effects (replaces $: { ... } reactive blocks)
|
||||||
|
$effect(() => {
|
||||||
|
// Runs when dependencies change
|
||||||
|
console.log("count changed:", count)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSR-safe code
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte"
|
||||||
|
|
||||||
|
// Guard browser-only APIs
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// untrack: capture initial value without creating reactive dependency
|
||||||
|
// Used in App.svelte for SSR initial URL
|
||||||
|
untrack(() => {
|
||||||
|
if (url) { /* set initial location */ }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Svelte stores in Svelte 5
|
||||||
|
|
||||||
|
Stores (`writable`, `derived`) still work in Svelte 5. Use `$storeName` syntax in components:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { location } from "./lib/store"
|
||||||
|
// $location is reactive — auto-subscribes in Svelte 5
|
||||||
|
</script>
|
||||||
|
<p>Current path: {$location.path}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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<MyType[]>("mycollection", { filter: { active: true }, limit: 20 })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const result = await api<ContentEntry[]>("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
|
||||||
|
<script>
|
||||||
|
import { _ } from "./lib/i18n/index"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{$_("hero.title")}</h1>
|
||||||
|
<p>{$_("hero.subtitle", { values: { name: "World" } })}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
34
AGENTS.md
34
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/<collection>` (e.g. `CODING_URL/api/content`).
|
- API access to collections uses the reverse proxy: `CODING_URL/api/<collection>` (e.g. `CODING_URL/api/content`).
|
||||||
- Auth via `Token` header with ADMIN_TOKEN from `api/config.yml.env`.
|
- 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
|
## Reference repositories
|
||||||
|
|
||||||
These sibling repos in the workspace provide documentation, types, and reference implementations:
|
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.
|
- **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.
|
- 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
|
### Tailwind CSS 4 canonical classes
|
||||||
|
|
||||||
This project uses Tailwind CSS 4. Always use the canonical v4 syntax:
|
This project uses Tailwind CSS 4. Always use the canonical v4 syntax:
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ Svelte 5 SPA bundled with esbuild and styled with Tailwind CSS 4.
|
|||||||
- `src/css/` — global styles and Tailwind imports.
|
- `src/css/` — global styles and Tailwind imports.
|
||||||
- Keep route components in a `src/routes/` folder when needed.
|
- 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 `<a>` 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
|
## Conventions
|
||||||
|
|
||||||
- PascalCase component names; export props at the top of `<script>` tag.
|
- PascalCase component names; export props at the top of `<script>` tag.
|
||||||
|
|||||||
Reference in New Issue
Block a user