Files
my-notes-viewer/.agents/skills/admin-ui-config/SKILL.md
T
apairon 4020ad62c5 feat: enhance medialib image handling and add asset URL resolution
- Implemented `resolveApiAssetUrl` function to normalize asset URLs based on API base.
- Updated `MedialibImage` component to utilize new asset URL resolution and added support for alt text and class properties.
- Enhanced image loading behavior with improved width measurement and focal point handling.
- Added placeholder image handling and improved accessibility with alt text.
- Introduced new test script for auditing broken links in skill documentation.
- Expanded seeded test content to include medialib entries and updated related tests for pagebuilder previews.
- Improved global setup and teardown logging for clarity on seeded content management.
2026-05-17 00:52:41 +00:00

784 lines
23 KiB
Markdown

---
name: admin-ui-config
description: Configure the admin UI for collections and project-level Nova behavior — meta labels, preview/viewHint, sidebar layout, collectionGroups, i18n, field widgets, foreign references, and image handling. Use when setting up or customizing admin views.
---
# admin-ui-config
## When to use this skill
Use this skill when:
- Configuring how a collection appears in the tibi-admin UI
- Configuring collection preview and default list presentation
- 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`. Always consult this file for the full API. This skill provides a practical summary.
Treat this skill as Nova-first. Use current Nova concepts such as `preview`, `singleton: { enabled }`, `drillDown`, `dependsOn`, `containerProps.layout`, `pagebuilder`, `viewHint`, `subNavigation`, and AI media assist.
---
## Project-level admin contracts
Not every important Nova contract lives in a collection YAML file. Some of the most important admin behaviors are configured at project level in `api/config.yml` under `meta:`.
Current starter example:
```yaml
meta:
imageUrl:
eval: "$projectBase + '_/assets/img/admin-pic.svg'"
i18n:
defaultLanguage: de
languages:
- code: de
label: Deutsch
- code: en
label: English
collectionGroups:
- name: content
label: { de: "Inhalte", en: "Content" }
icon: article
- name: media
label: { de: "Medien", en: "Media" }
icon: image_multiple
```
Treat these as part of the admin design, not as optional polish:
- `meta.imageUrl` — project card/preview imagery in the admin
- `meta.i18n` — project-wide language model for field-level and entry-level translation workflows
- `meta.collectionGroups` — ordered collection groups for the sidebar
Important rule:
- collection-level `meta.group` must reference one of the project-level `meta.collectionGroups[].name` values if the collection should appear inside an explicit group
If project-level `meta.i18n` is missing or inconsistent, even well-modeled collections can become confusing in Nova.
---
## Collection meta configuration
The `meta` key in a collection YAML controls how the collection appears in the admin sidebar and collection/list UI.
```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:
enabled: false
hide: false # Set to true to hide the collection for non-admin users
preview:
label: name
secondary: price
```
### Preview
Use `meta.preview` as the universal entry representation for Nova lists, breadcrumbs, foreign-key widgets, and search result previews:
```yaml
preview: name
preview:
label: name
secondary: slug
badge: status
preview:
eval: "`${$this.firstName} ${$this.lastName}`"
```
## List presentation
For current Nova, use `meta.viewHint` plus `meta.preview` for collection/list presentation.
```yaml
meta:
viewHint: table
preview:
label: name
secondary: slug
badge: status
table:
- name
- source: status
label: Status
- source: author.name
label: Author
select:
- author.name
```
- `meta.viewHint` controls the preferred collection presentation (`table`, `cards`, `media`, or `navigation` object where supported).
- `preview.table` defines explicit list columns for Nova.
- `preview.select` can reduce lookup work for preview table columns.
- `meta.subNavigation` defines filtered entry tabs in the sidebar.
### Sub-navigation tabs
Use `meta.subNavigation` when one collection needs multiple curated views in the admin without splitting into multiple collections.
```yaml
meta:
subNavigation:
- name: pages
label: { de: "Seiten", en: "Pages" }
muiIcon: article
filter:
type: page
defaultSort:
field: insertTime
order: DESC
setDefault:
field: type
value: page
- name: news
label: { de: "News", en: "News" }
muiIcon: feed
filter:
type: news
setDefault:
field: type
value: news
```
Use sub-navigation when:
- one collection has several stable editorial slices
- the underlying schema is still shared enough to stay one collection
- authors benefit from filtered entry views and sensible defaults
Do not use sub-navigation to hide a bad collection model. If the workflows truly diverge, split the collection instead.
---
## 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 | |
| `number[]` | Number chip array | Multiple numeric values |
| `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 | |
| `any` | JSON editor | For mixed/arbitrary data |
### 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: heroImage
type: file
meta:
widget: image # Image-focused file widget
- name: relatedPages
type: string[]
meta:
widget: foreignKeyChipArray
```
Common widget types: `text`, `checkbox`, `select`, `chipArray`, `checkboxArray`, `date`, `datetime`, `file`, `image`, `richtext`, `json`, `foreignKey`, `foreignKeyChipArray`, `pagebuilder`, `containerLessObject`, `containerLessObjectArray`.
Important current widgets/features to consider when designing a real website backoffice:
- `pagebuilder` for CMS-driven block/page authoring
- `foreignKeyChipArray` for many-reference editing
- `image` plus `imageEditor` / `downscale` for image-heavy workflows
- `drillDown` editing for complex nested arrays
### 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:
label: name
secondary: email
createDefaults:
role: author
```
Use `foreign.id: id` for the outward FK field identity. Only Mongo-style filters/query conditions use `_id`. Use `foreign.render` or target-collection `meta.preview` so references stay readable. Bare IDs are not acceptable authoring UX for a serious website project.
### Image fields
```yaml
- name: image
type: file
meta:
widget: image
downscale: # Auto-resize on upload
maxWidth: 1920
maxHeight: 1080
quality: 0.85
imageEditor: true # Enable crop/rotate editor
```
This field config controls the editor widget, not the filesystem target. Configure file storage once at collection level via top-level `uploadPath` (for this starter typically `../media/<collection>`), not on the individual file field.
---
## 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:publishing" # Sidebar with group key
```
### Sidebar groups (ordered)
Define sidebar group order in collection meta:
```yaml
meta:
sidebar:
- group: publishing
label: { de: "Veröffentlichung", en: "Publishing" }
- group: seo
label: { de: "SEO", en: "SEO" }
- group: settings
label: { de: "Einstellungen", en: "Settings" }
```
### 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
```
`containerProps.layout` is one of the most important Nova ergonomics features. Use it aggressively to avoid long, single-column forms.
Recommended pattern for real projects:
- sidebar for publication, SEO, flags, relations, admin-only metadata
- main area for editorial content
- 2-column or 3-column layout for short related fields
- section headings for repeated conceptual groups
---
## 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" }
widget: pagebuilder
preview: { eval: "`${$this.type}: ${$this.headline || ''}`" }
drillDown: true
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 arrays
For complex `object[]` data, prefer `drillDown: true` over dense inline editing. This is especially important for:
- nested content blocks
- FAQs / accordions
- team members with nested metadata
- pricing tables / feature matrices
### Pagebuilder fields
Nova supports pagebuilder configuration at both collection and field level.
Typical pattern:
```yaml
meta:
pagebuilder:
blockTypeField: type
defaultViewport: desktop
blockRegistry:
file: /_/assets/dist/admin.mjs
fields:
- name: blocks
type: object[]
meta:
widget: pagebuilder
pagebuilder:
blockTypeField: type
```
Use pagebuilder when editors work with heterogeneous content blocks. Use plain `object[]` only when the structure is uniform and simple.
### dependsOn
Use `dependsOn` to show only fields relevant to the selected block or mode:
```yaml
- name: image
type: file
meta:
dependsOn:
eval: $parent.type == 'hero'
```
This is critical for keeping pagebuilder schemas usable.
### AI-aware media and admin features
Current Nova types support AI-related admin capabilities, especially around media workflows. When appropriate for a project:
- use AI-assisted alt/caption generation for image-heavy collections
- prefer explicit target fields for generated metadata
- keep AI assist opt-in and editorially reviewable
Use AI only where it improves authoring quality; do not force it into every collection.
## Field-level permissions and authoring safety
Current tibi-server supports `readonlyFields`, `hiddenFields`, and eval-based field visibility/readonly rules.
Reflect these server rules in admin design:
- do not put critical computed fields front-and-center if editors may not be allowed to modify them
- use `dependsOn`, `hidden`, and readonly semantics deliberately
- remember that server-side permissions are authoritative even if the UI looks editable
### 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 the **pagebuilder block registry** and optional custom Svelte components for the tibi-admin UI. This is how the admin preview renders your Svelte blocks.
### Pagebuilder block registry
The current starter uses `createContentBlockDefinition()` to register each block type. This mounts real Svelte block components into Shadow DOM for admin previews:
```typescript
import { mount, unmount, type Component, type SvelteComponent } from "svelte"
import BlockRenderer from "./blocks/BlockRenderer.svelte"
// Creates a block definition that renders the same Svelte component
// used in the public frontend. The block is mounted inside Shadow DOM
// for style isolation.
function createContentBlockDefinition(presentation: { label: string; icon: string; color: string }) {
return {
css: [previewCssUrl], // CSS files to inject into Shadow DOM
label: presentation.label,
icon: presentation.icon,
color: presentation.color,
previewStyles: {
"background-color": "white",
},
render(container, row, context) {
// Mount the Svelte component inside the admin preview
const target = document.createElement("div")
container.appendChild(target)
let mountedComponent = mount(BlockRenderer as Component<any>, {
target,
props: { blocks: [row], isAdminPreview: true },
})
return {
update(nextRow) {
unmount(mountedComponent)
target.innerHTML = ""
mountedComponent = mount(BlockRenderer as Component<any>, {
target,
props: { blocks: [nextRow], isAdminPreview: true },
})
},
destroy() {
unmount(mountedComponent)
target.remove()
},
}
},
}
}
const blockRegistry = {
hero: createContentBlockDefinition({ label: "Hero", icon: "image", color: "#1d4ed8" }),
richtext: createContentBlockDefinition({ label: "Richtext", icon: "article", color: "#7c3aed" }),
// ... add new blocks here
}
export { blockRegistry }
```
**Key points:**
- Each registry entry wraps the Svelte `BlockRenderer` to render the block in the admin preview.
- The `row` object is the block data (same shape as `ContentBlockEntry`).
- Preview data may contain hydrated `_lookup.<fieldPath>` foreign key data and absolute file URLs — do not prepend `apiBase` or attempt re-fetching.
- The `previewCssUrl` loads the project's `index.css` into Shadow DOM so block styles apply.
- After adding blocks to the registry, run `yarn build` so `frontend/dist/admin.mjs` is regenerated.
### Custom Svelte components (advanced)
For custom dashboard widgets, preview components, or field widgets that require Svelte rendering inside the admin UI, use `getRenderedElement()`:
```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
}
export { getRenderedElement }
```
### Build
Run `yarn build`. The admin module (`frontend/src/admin.ts`) is compiled into `frontend/dist/admin.mjs` as part of the esbuild build pipeline (the same build produces both `index.mjs` for the SPA and `admin.mjs` for the admin module). tibi-admin-nova loads this module from the project's asset path (`/_/assets/dist/admin.mjs`). The `ADMIN_ASSET_VERSION` from `config.yml.env` is appended as a query parameter for cache busting: `admin.mjs?v=${ADMIN_ASSET_VERSION}`.
---
## Complete collection example
```yaml
name: products
meta:
label: { de: "Produkte", en: "Products" }
muiIcon: inventory_2
group: shop
viewHint: table
defaultSort:
field: insertTime
order: DESC
preview:
label: name
secondary: sku
badge: active
table:
- name
- sku
- source: price
label: { de: "Preis", en: "Price" }
- source: category
label: { de: "Kategorie", en: "Category" }
sidebar:
- group: publishing
label: { de: "Veröffentlichung", en: "Publishing" }
- group: seo
label: { de: "SEO", en: "SEO" }
permissions:
public:
methods:
get: true
user:
methods:
get: true
post: true
put: true
delete: false # usually false for real editorial workflows
fields:
- name: active
type: boolean
meta:
label: { de: "Aktiv", en: "Active" }
position: "sidebar:publishing"
- 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: image
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
```
---
## Indexes and search
For production collections with many entries, consider adding indexes in the YAML:
```yaml
name: products
indexes:
- name: price_sort
key: [price]
- name: category_active
key: [category, -active] # -prefix for descending
- name: slug_unique
key: [slug]
unique: true
```
Search configurations can be added for advanced text/vector search:
```yaml
search:
- name: default
mode: text
fields: [name, description]
```
See `tibi-server/docs/04-collections.md` (sections on indexes and search config) for full reference.
## Checklist-facing verification
For a real project, do not stop after writing the YAML. Validate the authoring contract explicitly.
Minimum review points:
1. project-level `meta.i18n` and `meta.collectionGroups` are coherent
2. each collection has a readable `meta.preview`
3. list views show meaningful columns instead of raw IDs or empty rows
4. foreign references render with readable previews
5. sidebars and `containerProps.layout` produce usable forms
6. pagebuilder collections expose both a working chooser and working preview path
Committed admin Playwright coverage is preferred for stable contracts that should not regress.
## Common pitfalls
- **`meta.label` supports both strings and i18n objects** — Use i18n objects only when the collection or field label must be localized.
- **Project-level admin config is easy to forget** — `collectionGroups` and project-level `meta.i18n` live in `api/config.yml`, not in individual collection files.
- **`meta.group` without a matching project group** — The collection still exists, but the sidebar grouping model becomes inconsistent.
- **`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.