# Administration

Resource screens, role editing, jobs, site settings and inline editing.

## Admin

The built-in admin is available at `/nb-admin/`.

The admin uses DaisyUI components. The following shortcodes from `core/modules/admin/lib/` handle data loading for admin views:

- `[#get-resource-records resource=users role=table#]` - loads all records + table-filtered fields into `data.records` and `data.fields`
- `[#get-resource-record resource=users uuid=abc123#]` - loads a single record for editing, with i18n and encryption handling
- `[#get-resource-meta resource=users#]` - loads only field definitions into `data.fields`

The legacy `_dep_` admin UI has been removed. Active admin routes and templates live in the non-`_dep_` admin paths.

### Dashboard

`/nb-admin/` (`[#dashboard#]`, `core/modules/admin/lib/dashboard/`) is organized around what a user needs to act on, not one card per subsystem. It renders up to five bands, plus any application bands, each present only when it has something to show for the current user's role:

- **Needs attention** - failed jobs, a recent fatal error, low disk space, or (for `pull-core-updates`) stale assets: core changed build inputs (`css/`, `js/`, Tailwind config, `package*.json`, core templates, scripts, styles, text or static files) since the commit in `ext/static/app.core`, so `app.css`/`app.js` need a rebuild. `app_build_stale()` in `core/lib/app-build.php` decides; a missing stamp or unknown commit never warns. The git-pull API returns the same flag as `assets_stale`. Absent entirely if the role has none of the relevant `view-*` features.
- **Site status** - is the site current and healthy, in one glance. Every item follows the same shape: a dark, small uppercase label (Data/Core/Ext/System), then the one fact that actually answers "is this OK" rendered large but in a lighter tone (so labels anchor the eye and values don't shout), with supporting detail as a small caption underneath. For **Data**, the large fact is *when* ("18 hours ago", with the specific resource named in the caption below it - e.g. "Projects updated" - so it's directly checkable against the matching row in Your data, not an anonymous number). For **Core**/**Ext**, the large fact is *status* ("Up to date" or "N updates", amber when something's pending), with the raw "Updated X ago" timestamp demoted to the caption, plus an inline **Update now** for roles with `pull-core-updates`/`pull-ext-updates`. For **System** (`view-debug`), the large fact is a plain-language status - "OK" or "Low resources" (below 1GB RAM or 500MB disk free) - with the actual RAM/disk numbers as the caption and a **View debug** link into `/nb-admin/debug`. Everything here is a fact (with an action attached where one applies), never bare navigation - that's what Manage below is for.
- **Visits** - for roles with `view-stats`: visits, pageviews and bot/scanner requests over the last 30 days with the change against the 30 days before, and one bar per day (today partial, from the running log). PHP passes UTC hourly counts from `stats_recent_days()` in `core/lib/stats.php`; the `dashboard_stats` Alpine component groups them into the viewer's local days. See Request statistics.
- **Application bands** - every `ext/tpl/dashboard-band.tpl` and `ext/modules/<name>/tpl/dashboard-band.tpl`, rendered after Visits in name order (`dashboard_app_bands()`). This is how an application or ext module adds its own card without touching core. The template decides its own visibility (render nothing when the user may not see it) and uses the same section styling as Visits.
- **Your data** - the resources the current role can see, with record counts, disk usage, and last-updated time per resource.
- **Manage** - a section card like the others (`rounded-2xl bg-neutral-50 p-6 shadow`, same heading style as Your data), containing grouped clusters, one per resource, laid out with equal height (`items-stretch` on the row) so the row reads as one set rather than cards of mismatched size. Each cluster follows a strict internal order: a dark small uppercase heading, a caption fact (same `text-neutral-500` as Site status), then - only if the cluster genuinely has one - its single most important pill (e.g. `Media Library (N)`, `Jobs (N)`, or `Add user` when a cluster's main draw is a create action rather than a list), and finally a **secondary row** where everything else (remaining entry pills like `Users (N)`/`Roles (N)`, plus every other action) renders as plain underlined text links, all sharing one canonical link style (`dashboard_secondary_link_class()` in `dashboard.php`, reused as-is by the Site status "Update now"/"View debug" links and every `quick-action-*.tpl` partial - one style everywhere, not a per-context variant). A cluster has exactly one pill-weight element, never two competing for attention:
  - **Users & roles**: "N active sessions" caption, `Add user` pill (the create action is the main reason to visit this cluster), then `Users (N)` / `Roles (N)` / `Sign out everyone (N)` as secondary text links.
  - **Media library**: "Updated X ago" caption (the library's own last-modified time), `Media Library (N)` pill, then `Clear thumbnail cache` / `Delete unused media` as secondary text links.
  - **System**: "Scheduler last ran" caption, `Jobs (N)` pill (N = currently queued jobs - Jobs lives here, not its own cluster, since queue/scheduler and general site maintenance are the same "background infrastructure" concern), then `Run jobs now` / `Clear data cache` as secondary text links and `View system log` for roles with `view-system-log`. Data cache (`ext/data/.tmp/cache/_data`, the per-resource `data_read_all()` result cache from `core/lib/data.php` - normally self-invalidating on resource modification, but clearable here for troubleshooting) lives here rather than in Media library - it's a site-wide cache, not a media one, even though thumbnail cache (media-specific, `ext/static/_thumb_`) does belong in Media library.

  "Add role" is deliberately not a cluster action - not a daily action, so Roles is a plain secondary link, not a create button. The System cluster doubles as the only entry point into `/nb-admin/jobs` anywhere in the admin. Site settings is **not** a Manage cluster - it has no fact of its own to show, so it's a small gear-icon button in the dashboard's own header (top-right, `core/modules/admin/uri/nb-admin/dashboard-settings-btn.tpl`, gated on `edit-.config`) instead.

  Needs attention's items follow a similar "fact, then a light secondary pill action" shape (`core/modules/admin/lib/dashboard/attention.tpl`) - no em dashes separating message from action; the action sits on its own line below the message instead.

  Every Site status item and Manage cluster heading (`dashboard_status_heading()` in `dashboard.php`) uses the project's actual brand color (`text-primary`, from `ext/tailwind.theme.js`) rather than plain gray - one consistent accent, not a different hue per category. A per-category rainbow was tried and rejected (read as visual noise); a small colored icon badge per heading was also tried and rejected (it indented the label relative to the caption/value below it, breaking the column's flush-left edge). `text-primary` stays reserved for these headings only - amber/red keep their separate meaning (attention/warning) elsewhere on the page.

Users, Roles, and Media Library are **not** in the nimblybar's Resources sidebar dropdown (`core/tpl/nimblybar/menu-resources.tpl`) - that list shows only the project's own ext-defined resources. Users/Roles/Media Library are reachable from the dashboard's Manage clusters instead, keeping the sidebar scoped to "this project's data."

### Roles and permissions editor

`/nb-admin/roles/(id)` and `/nb-admin/roles/add` share one permissions matrix component (`core/modules/admin/lib/role-permissions/`, `[#role-permissions [#role-id#]#]` / `[#role-permissions new#]`) so the add and edit flows never drift apart. The matrix renders one row per resource (including the hidden `.content`, `.config`, `.files`, `.jobs` resources) with per-operation checkboxes plus a `manage-<resource>` column, and a separate `(all)` toggle for super-admin roles. Unchecking `(all)` never clears the individual checkboxes underneath - they are only hidden while `(all)` is active, so switching it off restores whatever was set before.

Identity (name/description) and permissions are saved together from a single form (`core/modules/admin/lib/role-identity/`) via one PUT/POST to the generic `/api/v1/roles` endpoint - there is no separate role-permissions API. The client (`matrix-sync.js`) collapses a row's checked operations into `manage-<resource>` when every operation is checked, and builds the final comma-joined `features` string sent as a plain field alongside the role's name/description.

`.files_meta`, `files_unused` and `files_usage` are not resources with their own row in the matrix - they are internal, derived data. `permission_normalize_hidden_alias()` (`core/modules/user/lib/permissions.php`) aliases them to `.files`, so granting a role any operation on the **Files** row also covers file metadata lookups, the unused-file check and the "Used in" filter the media picker relies on. Do not add separate matrix rows or permission tokens for them.

### Jobs and Site settings

`/nb-admin/jobs` (`core/modules/admin/lib/jobs-panel/`) is a diagnostic view over the job queue and scheduler - queued/running/done/failed counts, a table of jobs with their last error, and "Run due jobs now" / "Prune old jobs" actions, gated on `view-.jobs` / `manage-.jobs`. "Prune old jobs" removes both `done` jobs (aged off `completed_at`) and terminally `failed` jobs with no retries left (aged off `_modified`) older than 30 days - the same rule the scheduled `jobs:prune --days=30` task applies, so a fixed-and-forgotten failure eventually drops off the **Needs attention** band on its own. Each `done`/`failed` row also has its own **Delete** button (gated on `manage-.jobs`) for clearing an individual job immediately regardless of age - queued/running rows show it disabled, since deleting an in-flight job could orphan a lock or a running process.

`/nb-admin/settings` (`core/modules/admin/lib/site-settings/`) is one flat page gated on `edit-.config`. It edits the site-wide `.config/site` record: name, description, languages, text direction (`direction`, `ltr`/`rtl`, rendered as `<html dir>`) and admin sidebar position (`nimblybar.side`). Name, description, direction and side are per-language values, and each field has its own language switch. On save, direction and side are filled in for every site language: Arabic, Persian and Hebrew start `rtl` with the sidebar on the right, and other languages start `ltr` and left. For users with `manage-system`, it also holds the custom pages switch with its page types, and the navigation editing switch (`.config/managed_pages`). Each switch shows only when there is a `pages` resource or navigation slots to switch on. Pages appear under Resources in the Nimbly bar; the Navigation admin tab is hidden for everyone while its switch is off. This is distinct from the per-page title editor behind the nimblybar gear icon, which edits `page_settings.page_title` for the current page only.

### Resource record switcher

Generic resource edit pages (`/nb-admin/(resource)/(id)`) include a record switcher (`core/modules/admin/lib/resource-switcher/`) so an editor can jump between records without going back to the list. It picks a UI based on record count: a handful of records render as pills, up to ~100 render as a native `<select>` (browsers already support type-ahead-to-jump), and beyond that it switches to a debounced live-search input backed by `resource_get()`'s `search`/`limit` GET params (`core/modules/api/lib/api.php`) rather than loading every record client-side.

---

## Rich content fields: end-to-end

This section shows the full flow for an editable HTML field: resource definition → template output → inline admin editing.

### Step 1: Define the field in `.meta`

```json
"intro": {
  "name": "Intro",
  "type": "html",
  "buttons": "bold,italic",
  "admin_col": false,
  "i18n": true
},
"body": {
  "name": "Body",
  "type": "html",
  "buttons": "h2,h3,h4,bold,italic,orderedlist,unorderedlist,quote,anchor",
  "media": true,
  "media_sizes": "sm-90,md-70,lg-60,xl-50,xxl-40",
  "admin_col": false,
  "i18n": true
}
```

- `buttons` is mandatory - always specify the exact toolbar buttons needed
- `media: true` enables the media browser; requires `media_sizes`
- `admin_col: false` is mandatory for html fields (too large for table view)
- Add `i18n: true` if the site is multi-language

### Step 2: Load the record in route.inc

```php
<?php
router_deny();
$parts = router_match(__FILE__);
if ($parts === false) return;
load_library('data');
$record = data_read('articles', $parts[0]);
if (!$record) return;
set_variable('article', $record);
router_accept();
```

### Step 3: Output in the template

Use `[#get-html#]` to output an editable html field. When a logged-in admin views the page, the field becomes inline-editable directly in the frontend.

```html
<div class="prose">
  [#get-html article.intro#]
</div>

<div class="prose prose-lg">
  [#get-html article.body#]
</div>
```

For multi-language content, `[#get-html#]` resolves i18n field objects automatically. Add a language suffix when you explicitly need one language:

```
[#get-html article.body#]
[#get-html article.body.nl#]
```

### Step 4: Enable inline editing

Add `[#html#]` to the route and ensure the user module is active (auto-loaded). Logged-in editors (`edit-inline-content`) then get the page actions pill: **Edit** switches inline editing on and off (asking to save or discard unsaved changes), and the settings button opens page settings or the record's admin page (`page-settings-link`). The pill can be dragged anywhere and docks in a corner when dropped near one. While editing, the focused field shows its field bar with Save.

### Inline editing attributes

For finer control over the inline editor (e.g. different toolbar than the `.meta` default, or to enable media on a field that doesn't have it in `.meta`), add `data-nb-edit` directly to a wrapper element:

```html
<div data-nb-edit="event.[#record.uuid#].main_text" data-nb-edit-options='{
    "buttons":"h2,h3,bold,italic,anchor",
    "media": true,
    "media_sizes":"md-70,lg-60"}'>
  [#get-html record.main_text#]
</div>
```

For inline image replacement, use `data-nb-edit-img` on the image wrapper:

```html
<div data-nb-edit-img="event.[#record.uuid#].main_img">
  [#get-img-html [#record.main_img#] sizes="xs-100,md-50"#]
</div>
```

Both attributes only activate for logged-in admins. The value format is `resource.uuid.field`.

---
