# Content Management

The CMS content system is shared application code backed by the CMS database. For table keys and relationships, read [04-database-relationships.md](04-database-relationships.md) first. Its main files are:

| Area | Files |
|---|---|
| Model | `src/model/base/content.model.php`, `src/model/cms/content.model.php` |
| Admin controller | `src/pages/admin/content-management/content-management.controller.php` |
| Admin views | `content-management.view.php`, `content-editor.view.php` |
| Admin scripts | `content-management.js`, `content-editor.js` |
| Public rendering | `src/core/modules/Content/Content.php` |
| GM admin bar | `src/core/admin-bar/admin-bar.php` |
| Schema | `installation/cms.sql` |

## Data Model

`cms_content_nodes` stores the tree:

- `node_type`: `category` or `content`.
- `parent_id`: nullable category parent.
- `list_style`: `news` or `dropdown` for categories when opened directly.
- `image`: optional node image filename stored under `assets/uploads/content/nodes/{node-id}/`.
- `preview_style`: `description`, `children`, or `dropdown` when the category is rendered inside another category.
- `children_sort`: `order_asc`, `order_desc`, `publish_asc`, or `publish_desc`.
- `sort_order`: manual sibling order used by the order-based options.
- `created_at`: node creation timestamp.
- `publish_at`: node publication timestamp. It is editable from the content editor and is shared by all translations.
- `sticky`: numeric sticky mode for categories and content: `0` = No, `1` = Only first page, `2` = Always.
- `protected`: boolean deletion guard. Protected nodes can still be edited, disabled, reordered, and recovered, but cannot be deleted. Their English slug cannot be modified.
- `deleted_at` and `deletion_batch_id`: soft-delete and recovery state.

`cms_content_translations` stores one title, globally unique slug, body, and draft flag per node and language. Node images are shared by all translations.

Categories may have an empty body. Content nodes require a title, body, and publish date when saved from the admin editor.

## Publication

An item is public only when:

1. Its translation exists for the requested language, or the public lookup explicitly falls back to English.
2. Its translation is not a draft.
3. Its node `publish_at` is not in the future.
4. Every parent category has a non-draft translation in the same resolved language.
5. The node is not soft-deleted.

`ContentModel::getPublishedChildren()` filters future publications, sorts sticky content first, and then applies the parent category's `children_sort` setting. `Always` sticky items are queried separately so future pagination can fetch them independently of the page.

Nested category previews use `preview_style`: description-only keeps the category description, children lists clickable child titles with publication dates, and dropdown uses the FAQ accordion markup.

## Slugs

Slug lookup ignores the language encoded by the translation that owns the slug. The matching node is resolved in the current application language before publication checks and rendering.

Generated slugs are normalized to lowercase ASCII. If the base slug is already used anywhere in the content tree, candidates are tried in this order:

1. `{slug}-{language}`
2. `{slug}-{language}-{node-id}`
3. `{slug}-{language}-{node-id}-{counter}`

The global database unique key prevents duplicate slugs even when translations use different languages.

The admin editor disables the English slug field for protected nodes. The controller preserves the stored English slug and ignores submitted changes.

Theme language selectors use `languagePageUrl()`. On a public content page, it lazily loads all translation slugs with one `ContentModel::getTranslationSlugs()` query and caches the map for the request, producing `{language}/{translated-slug}` without one query per language. When the requested translation is missing, the current effective slug is retained so the existing English fallback can resolve the same node. Existing query parameters are preserved, except for the routing-only `lang` parameter. Other pages continue to use `{language}/{page}`.

## Admin Workflow

The content tree supports drag-and-drop sibling ordering and category reparenting. `save_tree` persists `parent_id` and `sort_order` through `ContentModel::saveTree()`.

`ContentModel::getTree()` reads rows with `PDOStatement::fetch()` and stores separate short-lived JSON snapshots for active and deleted trees. Content mutations invalidate both snapshots. Controllers prepare category options and tree data; views must not call `getTree()` directly. The admin tree omits delete actions for protected nodes and categories containing active protected descendants; the model enforces the same rule server-side.

The editor's parent-category selector presents categories in depth-first parent-first order, preserving sibling `sort_order` and using ASCII prefixes to show nesting. The current node and its descendants are not offered as parent options.

`renderContentCategory()` caches complete top-level category HTML in the `content-category` cache namespace using the current slug and language. Nested previews are rendered inside the parent output and do not create separate child page caches. News item templates live in `src/core/modules/Content/templates/` and may be overridden per theme under `src/templates/{theme}/content/`.

## Manual PHP Content Loading

When a PHP page manually renders a CMS category or content node, load the content model and shared content helpers in the page controller, then resolve the node before the theme header is included:

```php
<?php
loadModel('content');
require_once CORE.'modules/Content/Content.php';

$contentNode = ContentModel::findPublishedBySlug('news', LANGUAGE);
```

The controller variable is available to the theme header, so the GM admin bar can identify the current category or content node. The view should reuse that resolved node instead of querying the slug again:

```php
<?php
if ($contentNode) {
  renderContentCategory($contentNode, false);
}
```

Do not perform the first content lookup only in the view when shared layout features need the current node. Controllers execute before the header; views execute after it.

The editor uses SunEditor and writes the editor HTML back through `editor.$.html.get()` before form submission. Node images are uploaded in the same multipart form submission. The image editor accepts drag-and-drop only, previews images through SweetAlert, and confirms replacement/removal before submitting. Local recovery drafts are stored in `localStorage` per node and language. A draft is restored only when it differs from the server content and is not older than the node's `modified_at` value.

In the admin content-management route, the website language remains the URL/session `LANGUAGE`; the translation being edited is selected with the `content_language` query parameter. New content uses the current website language. The editor displays the selected language before its page title and title field label.

The optional FLMngr picker is enabled through `src/settings/config-admin.php`. When enabled, it replaces SunEditor's image gallery, file gallery, and file browser selection behavior while preserving the existing direct upload endpoints. FLMngr uses the existing `assets/uploads/content/` directory, and its PHP backend is guarded by the content-management admin route. `allow_delete` controls server-side file and directory deletion; disabled deletion uses picker-only editor behavior and rejects delete requests.

The shared `src/assets/js/generic.js` navigation guard warns before leaving a page with unsaved form changes. The editor clears the guard when submitting successfully; the list back link includes the relevant `open` node IDs so the tree reopens the previous branch.

## GM Admin Bar

`src/core/admin-bar/admin-bar.php` exposes `renderAdminBar()` for direct inclusion by each theme layout. Every theme header must include it immediately after `<body>` opens using the external GM guard:

```php
<?php
if (isAdminAccount()) {
  require_once CORE . 'admin-bar/admin-bar.php';
  renderAdminBar();
}
?>
```

The method also checks the account internally and is intentionally not called by the core bootstrap.

The bar always links to the account panel, content management, and orders. The Orders option includes Transaction Details and Manual Sell sub-options. On public content pages it uses `$contentNode` to expose category and content editor links. `contentNodeCategory()` in `src/core/modules/Content/Content.php` resolves the current category directly or loads the parent category for a content node. Editor links use the effective translation language and the language-prefixed route format.

Bar styles are scoped to `.admin-bar-*` selectors in `src/assets/css/global.css`. The renderer emits Font Awesome classes but does not import the Font Awesome stylesheet, keeping inclusion template-agnostic. At viewport widths up to 768px, the bar is collapsed behind a CSS-only hamburger toggle. When expanded, it uses a vertical touch-friendly layout; category sub-options span the full row beneath the category action with left indentation. No separate mobile renderer or mount call is required.

## Schema Updates

Deleting a category is rejected when it contains an active protected descendant at any depth. Unprotected active descendants are soft-deleted in the deletion batch. Already-deleted protected descendants in that subtree remain deleted and are detached to the root.

## Asset Workflow

Run `npm run copy-assets` after installing frontend dependencies. It copies SunEditor, FLMngr, and Font Awesome runtime assets into the core editor/file-manager folders. SunEditor runtime assets are under `core/editors/sun-editor/assets/`, FLMngr runtime assets are under `core/file-manager/flmngr/assets/`, and the integration wrapper is `core/editors/sun-editor/suneditor-wrapper.js`. The PHP dependency `edsdk/flmngr-server-php` is installed through Composer. Shared templates import Font Awesome through `ComponentsTemplateBase::importFontAwesome()`; pages should not link dependency files directly.

## Changes

When changing the content API, update:

- `Docs/api/models.md` for public model signatures.
- This document for behavior, schema, or admin workflow changes.
- `installation/cms.sql` for schema changes.
- CMS model or web-flow tests when behavior changes.
