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