Files
tibi-svelte-starter/.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

23 KiB

name, description
name description
admin-ui-config 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:

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.

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:

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.

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.

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

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:

# 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:

- 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:

- 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:

- name: category
  type: string
  meta:
      choices:
          endpoint: categories # Collection name
          mapping:
              id: id
              name: name

Foreign references

Link to entries in another collection:

- 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

- 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

- 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:

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

- 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:

- 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)

- 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)

- 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:

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:

- 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:

- 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:

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():

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

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

For production collections with many entries, consider adding indexes in the 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:

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 forgetcollectionGroups 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.