✨ 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`.
|
||||
Reference in New Issue
Block a user