diff --git a/README.md b/README.md
index c7f066a..f737552 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ If you've ever done a fresh CodeIgniter 3 install on PHP 8.2+ and immediately se
- [Authentication](#authentication)
- [Accounts & e-mail (verification, password reset, Google)](#accounts--e-mail-verification-password-reset-google)
- [Roles & permissions](#roles--permissions)
+- [Menu builder & page builder](#menu-builder--page-builder)
- [Login security & audit log](#login-security--audit-log)
- [Exports](#exports)
- [Big data & several servers](#big-data--several-servers)
@@ -565,6 +566,21 @@ The starter is meant to grow into a large application, so these are built in rat
Not included (so you know): database read replicas / sharding, a job queue, S3 storage, and caching of settings/permissions (they are small queries on every request; role changes apply immediately because nothing is cached).
+## Menu builder & page builder
+
+Both are under the **Content** section of the admin sidebar and need `php index.php console migrate`. The `admin` role receives their permissions (except `pages.html`, below); `super_admin` has everything.
+
+**Menu builder** (`admin/menus`, permissions `menus.*`). Builds the admin sidebar without editing `application/config/menu.php`, stored in the database so changes show up at once. Three kinds of entry: a **link**, a **dropdown** (no address of its own; it opens the links put inside it, and an empty one is not shown) and a **section** heading. Entries nest up to three levels, are ordered by dragging or with the arrow buttons, and can each be limited to a permission or to super admins (items inside a dropdown follow their own permission). The items from `menu.php` always stay; the built ones are added after them.
+- **Icon picker.** The icon field has a *Choose* button that opens a searchable grid of all ~2,050 Bootstrap Icons (full screen on phones); typing a class by hand still works. The same picker is on the module generator's menu-icon field. To use it on your own form: ` ` and load `assets/js/icon-picker.js`. (`icons.json` is generated from the vendored CSS; regenerate it when you update the icon font.)
+- A link is a page of this site (`dashboard`, `page/about`) or an `http(s)://`, `mailto:` or `tel:` address. Anything else (`javascript:`, `data:`, `//host`, addresses with spaces) is refused, and it is checked again when the sidebar is drawn.
+- A parent must exist, be a link or a dropdown, and not be the item itself or one of its own descendants; a section is always top level and cannot hold items.
+
+**Page builder** (`admin/pages`, permissions `pages.*`). Builds public pages from blocks (hero, text, features, call to action, image, custom HTML), served at `/page/{address}`. The list is a server-side DataTable. A draft is a 404 for the public and a preview (not cacheable) for editors.
+- Text is escaped. The **Text block** may contain basic formatting; it is rebuilt from an allow-list (`App\Page\Html`): only `p br strong em b i u ul ol li h2 h3 h4 blockquote code pre span hr a`, no attributes except a safe `href` on links (which get `rel="noopener noreferrer"`), scripts and styles removed.
+- Link and image addresses go through `App\Page\Links`: `javascript:`, `data:` and similar never become a link; pages of this site are turned into full addresses, so `register` works from `/page/about`.
+- The **Custom HTML** block is raw on purpose and can run scripts for every visitor, so adding or changing one needs the separate permission **`pages.html`**. It is not in the `admin` role (not even on a fresh install); an editor without it can keep the HTML blocks a page already has, unchanged, but not add or edit one. Give it only to people you trust as much as a developer.
+- Colours are optional `#hex` values; empty uses the theme colour.
+
## One-click installer
On a fresh checkout the home page shows a **Finish setting up** card with an **Install now** button (it leads to `/install`). The form asks for the application name and your administrator account (and, only when there is no working connection, the database server, user, password and name) and then, in one go:
diff --git a/application/config/menu.php b/application/config/menu.php
index c1fd250..07294b5 100644
--- a/application/config/menu.php
+++ b/application/config/menu.php
@@ -32,6 +32,9 @@
array('section' => 'Access'),
array('label' => 'Users', 'icon' => 'bi-people', 'url' => 'admin/users', 'permission' => 'users.view'),
array('label' => 'Roles', 'icon' => 'bi-shield-lock', 'url' => 'admin/roles', 'permission' => 'roles.view'),
+ array('section' => 'Content'),
+ array('label' => 'Pages', 'icon' => 'bi-file-earmark-richtext', 'url' => 'admin/pages', 'permission' => 'pages.view'),
+ array('label' => 'Menu builder', 'icon' => 'bi-list', 'url' => 'admin/menus', 'permission' => 'menus.view'),
array('section' => 'System'),
array('label' => 'Settings', 'icon' => 'bi-gear', 'url' => 'admin/settings', 'permission' => 'settings.manage'),
array('label' => 'Login security', 'icon' => 'bi-shield-exclamation', 'url' => 'admin/security', 'permission' => 'security.view'),
diff --git a/application/config/permissions.php b/application/config/permissions.php
index 43bfa4c..d197117 100644
--- a/application/config/permissions.php
+++ b/application/config/permissions.php
@@ -88,3 +88,9 @@
'admin' => array('*'),
'user' => array(),
);
+
+/*
+| Permissions that '*' above does NOT include: they are as powerful as a developer's
+| access, so only super_admin has them until somebody grants them on the Roles page.
+*/
+$config['not_in_wildcard'] = array('pages.html');
diff --git a/application/config/routes.php b/application/config/routes.php
index 23f861d..1792809 100644
--- a/application/config/routes.php
+++ b/application/config/routes.php
@@ -104,3 +104,19 @@
$route['admin/audit'] = 'audit/audit_log/index';
$route['admin/audit/data'] = 'audit/audit_log/data';
$route['admin/audit/(\d+)'] = 'audit/audit_log/show/$1';
+
+// Menu builder (application/modules/menus): the admin sidebar, built without touching menu.php.
+$route['admin/menus'] = 'menus/menu_builder/index';
+$route['admin/menus/create'] = 'menus/menu_builder/create';
+$route['admin/menus/edit/(\d+)'] = 'menus/menu_builder/edit/$1';
+$route['admin/menus/delete/(\d+)'] = 'menus/menu_builder/delete/$1';
+$route['admin/menus/reorder'] = 'menus/menu_builder/reorder';
+
+// Page builder (application/modules/pages): content pages served at /page/{address}.
+$route['admin/pages'] = 'pages/pages/index';
+$route['admin/pages/data'] = 'pages/pages/data';
+$route['admin/pages/create'] = 'pages/pages/create';
+$route['admin/pages/edit/(\d+)'] = 'pages/pages/edit/$1';
+$route['admin/pages/delete/(\d+)'] = 'pages/pages/delete/$1';
+$route['page/([a-z0-9][a-z0-9-]{0,149})'] = 'pages/pages/view/$1';
+
diff --git a/application/helpers/datatable_helper.php b/application/helpers/datatable_helper.php
index 5de0bda..40c7f32 100644
--- a/application/helpers/datatable_helper.php
+++ b/application/helpers/datatable_helper.php
@@ -77,7 +77,7 @@ function datatable_table($id, $url, array $columns, array $options = [])
$attrs['data-filters'] = (string) $options['filters'];
}
if (isset($options['order'])) {
- $attrs['data-order'] = (int) $options['order'][0].','.(strtolower((string) ($options['order'][1] ?? 'asc')) === 'desc' ? 'desc' : 'asc');
+ $attrs['data-initial-order'] = (int) $options['order'][0].','.(strtolower((string) ($options['order'][1] ?? 'asc')) === 'desc' ? 'desc' : 'asc');
}
$html = '
config->load('menu', true, true);
$items = $CI->config->item('menu', 'menu') ?: [];
+
+ // Items built with Admin -> Menu builder (application/modules/menus), merged after the
+ // file-based ones. Quiet and guarded: a missing table or an unreadable row must never
+ // take the sidebar (or the page) down.
+ try {
+ if (!isset($CI->menu_model)) {
+ $CI->load->model('menus/menu_model', 'menu_model');
+ }
+ $built = $CI->menu_model->sidebar_items();
+ if ($built !== []) {
+ $items = array_merge($items, $built);
+ }
+ } catch (\Throwable $e) {
+ log_message('error', 'Menu builder items skipped: '.get_class($e).': '.$e->getMessage());
+ }
+
// Modules made with the CRUD generator (each already checked against the user's permissions).
$modules = $CI->crud_store->menu_entries();
if ($modules !== []) {
@@ -369,3 +385,17 @@ function brand_mark()
return ''.htmlspecialchars(mb_strtoupper(mb_substr((string) app_setting('app_name'), 0, 1)), ENT_QUOTES, 'UTF-8').' ';
}
}
+
+if (!function_exists('menu_href')) {
+ /**
+ * The address of a sidebar entry, ready to put inside href="...": a page of this site goes
+ * through site_url(), an http(s)/mailto/tel address stays as it is, and anything else
+ * (javascript:, data:, ...) becomes "#". Not escaped: wrap it in htmlspecialchars().
+ */
+ function menu_href($url)
+ {
+ $resolved = \App\Page\Links::resolve((string) $url, 'site_url');
+
+ return $resolved ?? '#';
+ }
+}
diff --git a/application/migrations/20261002000000_create_roles_tables.php b/application/migrations/20261002000000_create_roles_tables.php
index 4c5bab5..803a640 100644
--- a/application/migrations/20261002000000_create_roles_tables.php
+++ b/application/migrations/20261002000000_create_roles_tables.php
@@ -58,6 +58,7 @@ public function up()
$config = array();
include APPPATH.'config/permissions.php';
$defaults = $config['default_role_permissions'] ?? array();
+ $notInWildcard = $config['not_in_wildcard'] ?? array();
$files = array_merge(array(APPPATH.'config/permissions.php'), glob(APPPATH.'modules/*/config/permissions.php') ?: array());
$all = PermissionRegistry::fromFiles($files)->keys();
@@ -67,7 +68,7 @@ public function up()
if (!$role) {
continue;
}
- $granted = in_array('*', $permissions, true) ? $all : array_intersect($permissions, $all);
+ $granted = in_array('*', $permissions, true) ? array_diff($all, $notInWildcard) : array_intersect($permissions, $all);
foreach ($granted as $permission) {
$this->db->insert('role_permissions', array('role_id' => $role->id, 'permission' => $permission));
}
diff --git a/application/migrations/20261009000000_create_menus_table.php b/application/migrations/20261009000000_create_menus_table.php
new file mode 100644
index 0000000..764bc80
--- /dev/null
+++ b/application/migrations/20261009000000_create_menus_table.php
@@ -0,0 +1,40 @@
+ Menus (application/modules/menus).
+ *
+ * Rows are merged with application/config/menu.php by menu_items(); a row
+ * with type "section" is a small heading, anything else is a link (which may
+ * have children through parent_id).
+ */
+class Migration_Create_menus_table extends CI_Migration
+{
+ public function up()
+ {
+ $this->dbforge->add_field(array(
+ 'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true),
+ 'parent_id' => array('type' => 'INT', 'unsigned' => true, 'null' => true),
+ 'type' => array('type' => 'VARCHAR', 'constraint' => 20, 'default' => 'link'), // link | section
+ 'label' => array('type' => 'VARCHAR', 'constraint' => 120),
+ 'icon' => array('type' => 'VARCHAR', 'constraint' => 60, 'null' => true),
+ 'url' => array('type' => 'VARCHAR', 'constraint' => 255, 'null' => true),
+ 'permission' => array('type' => 'VARCHAR', 'constraint' => 100, 'null' => true),
+ 'super_admin' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 0),
+ 'position' => array('type' => 'INT', 'unsigned' => true, 'default' => 0),
+ 'is_active' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 1),
+ 'created_at' => array('type' => 'DATETIME', 'null' => true),
+ 'updated_at' => array('type' => 'DATETIME', 'null' => true),
+ ));
+ $this->dbforge->add_key('id', true);
+ $this->dbforge->create_table('menus');
+
+ $this->db->query('CREATE INDEX menus_parent_id ON menus (parent_id)');
+ $this->db->query('CREATE INDEX menus_position ON menus (position)');
+ }
+
+ public function down()
+ {
+ $this->dbforge->drop_table('menus');
+ }
+}
diff --git a/application/migrations/20261010000000_create_pages_table.php b/application/migrations/20261010000000_create_pages_table.php
new file mode 100644
index 0000000..41a0f50
--- /dev/null
+++ b/application/migrations/20261010000000_create_pages_table.php
@@ -0,0 +1,35 @@
+ Pages (application/modules/pages).
+ *
+ * The sections column holds the whole layout as JSON (see App\Page\SectionTypes)
+ * and is rendered by the pages module's view controller at /page/{slug}.
+ */
+class Migration_Create_pages_table extends CI_Migration
+{
+ public function up()
+ {
+ $this->dbforge->add_field(array(
+ 'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true),
+ 'slug' => array('type' => 'VARCHAR', 'constraint' => 150),
+ 'title' => array('type' => 'VARCHAR', 'constraint' => 190),
+ 'status' => array('type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'), // draft | published
+ 'sections' => array('type' => 'TEXT', 'null' => true),
+ 'meta_description' => array('type' => 'VARCHAR', 'constraint' => 300, 'null' => true),
+ 'created_at' => array('type' => 'DATETIME', 'null' => true),
+ 'updated_at' => array('type' => 'DATETIME', 'null' => true),
+ ));
+ $this->dbforge->add_key('id', true);
+ $this->dbforge->add_key('slug');
+ $this->dbforge->create_table('pages');
+
+ $this->db->query('CREATE UNIQUE INDEX pages_slug ON pages (slug)');
+ }
+
+ public function down()
+ {
+ $this->dbforge->drop_table('pages');
+ }
+}
diff --git a/application/migrations/20261011000000_grant_menus_pages_permissions.php b/application/migrations/20261011000000_grant_menus_pages_permissions.php
new file mode 100644
index 0000000..973ca8d
--- /dev/null
+++ b/application/migrations/20261011000000_grant_menus_pages_permissions.php
@@ -0,0 +1,34 @@
+db->get_where('roles', array('slug' => 'admin'))->row();
+ if (!$admin) {
+ return;
+ }
+
+ foreach ($this->permissions as $permission) {
+ $exists = $this->db->where(array('role_id' => $admin->id, 'permission' => $permission))->count_all_results('role_permissions') > 0;
+ if (!$exists) {
+ $this->db->insert('role_permissions', array('role_id' => $admin->id, 'permission' => $permission));
+ }
+ }
+ }
+
+ public function down()
+ {
+ $this->db->where_in('permission', array_merge($this->permissions, array('pages.html')))->delete('role_permissions');
+ }
+}
diff --git a/application/modules/generator/controllers/Module_generator.php b/application/modules/generator/controllers/Module_generator.php
index 64dd84e..424a74e 100644
--- a/application/modules/generator/controllers/Module_generator.php
+++ b/application/modules/generator/controllers/Module_generator.php
@@ -219,7 +219,7 @@ protected function form($module, array $values, array $errors)
$this->template
->set_title($module === null ? 'New module' : 'Edit '.$module['title'])
->set_breadcrumbs(['Home' => '', 'Generator' => 'admin/generator', ($module === null ? 'New module' : 'Edit') => null])
- ->add_foot('')
+ ->add_foot('')
->render('form', [
'module' => $module,
'values' => $values,
diff --git a/application/modules/generator/views/form.php b/application/modules/generator/views/form.php
index c35f2ae..ff6fc17 100644
--- a/application/modules/generator/views/form.php
+++ b/application/modules/generator/views/form.php
@@ -38,7 +38,7 @@
Menu icon
diff --git a/application/modules/menus/config/permissions.php b/application/modules/menus/config/permissions.php
new file mode 100644
index 0000000..cf5a236
--- /dev/null
+++ b/application/modules/menus/config/permissions.php
@@ -0,0 +1,23 @@
+ Menus edits the admin sidebar. The built-in items in
+| application/config/menu.php are always shown; these permissions only
+| decide who may change the menu.
+*/
+
+$config['permissions']['menus'] = [
+ 'label' => 'Menu builder',
+ 'icon' => 'bi-list',
+ 'permissions' => [
+ 'view' => 'See the menu builder',
+ 'create' => 'Add menu items',
+ 'edit' => 'Edit and reorder menu items',
+ 'delete' => 'Delete menu items',
+ ],
+];
diff --git a/application/modules/menus/controllers/Menu_builder.php b/application/modules/menus/controllers/Menu_builder.php
new file mode 100644
index 0000000..8fb017c
--- /dev/null
+++ b/application/modules/menus/controllers/Menu_builder.php
@@ -0,0 +1,270 @@
+ Menus: build the admin sidebar without touching menu.php.
+ *
+ * Routes (application/config/routes.php):
+ * GET /admin/menus menus.view the tree, drag to reorder
+ * GET|POST /admin/menus/create menus.create
+ * GET|POST /admin/menus/edit/{id} menus.edit
+ * POST /admin/menus/delete/{id} menus.delete
+ * POST /admin/menus/reorder menus.edit new order (JSON)
+ *
+ * The items in application/config/menu.php stay; what is built here is merged
+ * into the sidebar by menu_items() (application/helpers/theme_helper.php).
+ * A section row is a heading, a link row is an entry (links may be nested
+ * one level, depth 2 in the builder).
+ *
+ * @property Template $template
+ * @property Menu_model $menu_model
+ * @property Audit $audit
+ * @property Rbac $rbac
+ * @property CI_Input $input
+ * @property CI_Output $output
+ * @property CI_Form_validation $form_validation
+ */
+class Menu_builder extends MX_Controller
+{
+ public function __construct()
+ {
+ parent::__construct();
+ require_login();
+ $this->load->helper('form');
+ $this->load->model('menus/menu_model', 'menu_model');
+ }
+
+ public function index()
+ {
+ require_permission('menus.view');
+
+ $this->template
+ ->set_title('Menu builder')
+ ->set_subtitle('Drag an item onto another to nest it. Changes appear in the sidebar straight away.')
+ ->set_breadcrumbs(['Home' => '', 'Menu builder' => null])
+ ->add_foot('')
+ ->render('index', [
+ 'items' => $this->menu_model->all(),
+ 'available' => $this->menu_model->available(),
+ 'can_create' => can('menus.create'),
+ 'can_edit' => can('menus.edit'),
+ 'can_delete' => can('menus.delete'),
+ 'csrf_name' => $this->security->get_csrf_token_name(),
+ 'csrf_hash' => $this->security->get_csrf_hash(),
+ ], 'admin');
+ }
+
+ public function create()
+ {
+ require_permission('menus.create');
+
+ if ($this->input->method() === 'post') {
+ $this->save(null);
+
+ return;
+ }
+
+ $this->form(null, $this->menu_model->defaults(), []);
+ }
+
+ public function edit($id = 0)
+ {
+ require_permission('menus.edit');
+
+ $item = $this->menu_model->find((int) $id);
+ if (!$item) {
+ show_404();
+ }
+
+ if ($this->input->method() === 'post') {
+ $this->save($item);
+
+ return;
+ }
+
+ $this->form($item, $item, []);
+ }
+
+ public function delete($id = 0)
+ {
+ if ($this->input->method() !== 'post') {
+ show_404();
+ }
+ require_permission('menus.delete');
+
+ $item = $this->menu_model->find((int) $id);
+ if (!$item) {
+ show_404();
+ }
+
+ if ($this->menu_model->delete((int) $id)) {
+ $this->audit->deleted('menus', $item['id'], $item['label'], $item);
+ flash('success', 'Menu item deleted. Its children moved up one level.');
+ } else {
+ flash('error', 'The menu could not be changed. Run the migrations first: php index.php console migrate');
+ }
+
+ redirect('admin/menus');
+ }
+
+ /**
+ * The order the builder dropped things into: [[id, depth], ...].
+ */
+ public function reorder()
+ {
+ if ($this->input->method() !== 'post') {
+ $this->json(['ok' => false, 'message' => 'POST only.'], 405);
+
+ return;
+ }
+ require_permission('menus.edit');
+
+ $raw = json_decode((string) $this->input->post('order'), true);
+ $order = [];
+ foreach ((array) $raw as $pair) {
+ if (is_array($pair) && isset($pair[0], $pair[1])) {
+ $order[] = [(int) $pair[0], (int) $pair[1]];
+ }
+ }
+
+ $ok = $this->menu_model->reorder($order);
+ if ($ok) {
+ $this->audit->event('reorder', 'menus', null, 'Sidebar menu', ['order' => implode(',', array_map(static function ($p) {
+ return $p[0].':'.$p[1];
+ }, $order))]);
+ }
+
+ $this->json(['ok' => $ok, 'message' => $ok ? 'Menu saved.' : 'The order could not be saved.'], $ok ? 200 : 500);
+ }
+
+ /**
+ * @param array|null $item
+ * @param array $values
+ * @param array $errors
+ */
+ protected function form($item, array $values, array $errors)
+ {
+ $parents = array_values(array_filter($this->menu_model->all(), static function ($row) use ($item) {
+ // Items go inside a link or a dropdown, never inside a section or themselves.
+ return in_array($row['type'], [Menu_model::TYPE_LINK, Menu_model::TYPE_DROPDOWN], true) && (int) $row['id'] !== (int) ($item['id'] ?? 0);
+ }));
+
+ $this->template
+ ->set_title($item === null ? 'New menu item' : 'Edit '.$item['label'])
+ ->set_breadcrumbs(['Home' => '', 'Menu builder' => 'admin/menus', ($item === null ? 'New item' : 'Edit') => null])
+ ->add_foot('')
+ ->render('form', [
+ 'item' => $item,
+ 'values' => $values,
+ 'errors' => $errors,
+ 'parents' => $parents,
+ 'permissions' => $this->rbac->registry()->keys(),
+ ], 'admin');
+ }
+
+ /**
+ * @param array|null $item
+ */
+ protected function save($item)
+ {
+ $input = (array) $this->input->post(null, false);
+ $values = [
+ 'type' => in_array($input['type'] ?? '', [Menu_model::TYPE_SECTION, Menu_model::TYPE_DROPDOWN], true) ? $input['type'] : Menu_model::TYPE_LINK,
+ 'label' => trim((string) ($input['label'] ?? '')),
+ 'icon' => preg_replace('/[^a-z0-9 \-]/i', '', (string) ($input['icon'] ?? '')),
+ 'url' => trim((string) ($input['url'] ?? '')),
+ 'permission' => trim((string) ($input['permission'] ?? '')),
+ 'super_admin' => isset($input['super_admin']) ? 1 : 0,
+ 'is_active' => isset($input['is_active']) ? 1 : 0,
+ 'parent_id' => (int) ($input['parent_id'] ?? 0),
+ ];
+
+ $errors = [];
+ if ($values['label'] === '' || mb_strlen($values['label']) > 120) {
+ $errors['label'] = 'Enter a label of up to 120 characters.';
+ }
+ if ($values['type'] === Menu_model::TYPE_LINK) {
+ if ($values['url'] === '' || mb_strlen($values['url']) > 255) {
+ $errors['url'] = 'A link needs a URL of up to 255 characters (e.g. dashboard or https://…).';
+ } elseif (Links::safe($values['url']) === null) {
+ $errors['url'] = 'That address is not allowed. Use a page of this site (dashboard, page/about) or an http(s):// address.';
+ }
+ } else {
+ $values['url'] = ''; // dropdowns and sections have no address
+ }
+ if ($values['type'] === Menu_model::TYPE_SECTION) {
+ $values['icon'] = '';
+ }
+ if ($values['permission'] !== '' && !in_array($values['permission'], $this->rbac->registry()->keys(), true)) {
+ $errors['permission'] = 'Unknown permission "'.mb_substr($values['permission'], 0, 40).'". Pick one from the list.';
+ }
+ // A section is a heading: always top level, and it cannot hold links.
+ $parents = [];
+ foreach ($this->menu_model->all() as $row) {
+ $parents[(int) $row['id']] = (int) ($row['parent_id'] ?? 0);
+ }
+ $itemId = (int) ($item['id'] ?? 0);
+ $hasChildren = $itemId > 0 && in_array($itemId, $parents, true);
+
+ if ($values['type'] === Menu_model::TYPE_SECTION) {
+ $values['parent_id'] = 0;
+ if ($hasChildren) {
+ $errors['type'] = 'This item has items under it. Move or delete them before turning it into a section.';
+ }
+ } elseif ($values['parent_id'] !== 0) {
+ $parent = $this->menu_model->find($values['parent_id']);
+ if ($parent === null || !in_array($parent['type'], [Menu_model::TYPE_LINK, Menu_model::TYPE_DROPDOWN], true)) {
+ $errors['parent_id'] = 'Pick a link or a dropdown from the list as the parent.';
+ } elseif (!MenuTree::canNest($parents, $itemId, $values['parent_id'], Menu_model::MAX_DEPTH)) {
+ $errors['parent_id'] = 'That parent would put the item inside itself or nest the menu too deep (at most '.(Menu_model::MAX_DEPTH + 1).' levels).';
+ }
+ }
+
+ if ($errors !== []) {
+ if ($item === null) {
+ $this->form(null, $values + $this->menu_model->defaults(), $errors);
+ } else {
+ $this->form($item, $values + $item, $errors);
+ }
+
+ return;
+ }
+
+ if ($item === null) {
+ $id = $this->menu_model->create($values);
+ if ($id === null) {
+ flash('error', 'The item could not be saved. Run the migrations first: php index.php console migrate');
+ redirect('admin/menus');
+ }
+ $this->audit->created('menus', $id, $values['label'], $values);
+ flash('success', 'Menu item added.');
+ } else {
+ if (!$this->menu_model->update((int) $item['id'], $values)) {
+ flash('error', 'The item could not be saved. Run the migrations first: php index.php console migrate');
+ redirect('admin/menus');
+ }
+ $this->audit->updated('menus', $item['id'], $values['label'], $item, $values + $item);
+ flash('success', 'Menu item saved.');
+ }
+
+ redirect('admin/menus');
+ }
+
+ /**
+ * @param array $payload
+ */
+ protected function json(array $payload, $status = 200)
+ {
+ $this->output
+ ->set_status_header($status)
+ ->set_header('Cache-Control: no-store')
+ ->set_content_type('application/json', 'utf-8')
+ ->set_output(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT));
+ }
+}
diff --git a/application/modules/menus/models/Menu_model.php b/application/modules/menus/models/Menu_model.php
new file mode 100644
index 0000000..1337c30
--- /dev/null
+++ b/application/modules/menus/models/Menu_model.php
@@ -0,0 +1,299 @@
+ Menus (application/modules/menus).
+ *
+ * Rows are stored flat: parent_id + position. menu_items() in
+ * application/helpers/theme_helper.php asks for sidebar_items() and merges the
+ * result with application/config/menu.php, so a menu built here appears in the
+ * sidebar next to the built-in entries.
+ *
+ * Every query is quiet: before the migration has run — or with no database at
+ * all — the menu simply stays config-only and nothing breaks.
+ *
+ * @property CI_DB_query_builder $db
+ */
+class Menu_model extends CI_Model
+{
+ public const TYPE_LINK = 'link';
+ public const TYPE_SECTION = 'section';
+ public const TYPE_DROPDOWN = 'dropdown';
+
+ /** Deepest nesting the builder allows (keeps the sidebar readable). */
+ public const MAX_DEPTH = 2;
+
+ /** @var bool|null table existence, checked once per request */
+ protected $available;
+
+ /**
+ * Every row, ordered for display: top level first, children under their parent.
+ *
+ * @return array>
+ */
+ public function all()
+ {
+ $rows = $this->rows();
+ $out = [];
+
+ $byParent = [];
+ foreach ($rows as $row) {
+ $byParent[(int) ($row['parent_id'] ?? 0)][] = $row;
+ }
+
+ $walk = function ($parentId, $depth) use (&$walk, &$out, $byParent) {
+ foreach ($byParent[$parentId] ?? [] as $row) {
+ $row['depth'] = $depth;
+ $out[] = $row;
+ $walk((int) $row['id'], $depth + 1);
+ }
+ };
+ $walk(0, 0);
+
+ return $out;
+ }
+
+ /**
+ * @param int $id
+ *
+ * @return array|null
+ */
+ public function find($id)
+ {
+ foreach ($this->rows() as $row) {
+ if ((int) $row['id'] === (int) $id) {
+ return $row;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * The items a new row starts out with (used for defaults and validation).
+ *
+ * @return array
+ */
+ public function defaults()
+ {
+ return ['type' => self::TYPE_LINK, 'label' => '', 'icon' => '', 'url' => '', 'permission' => '', 'super_admin' => 0, 'is_active' => 1];
+ }
+
+ /**
+ * @param array $data
+ *
+ * @return int|null the new id, null on failure
+ */
+ public function create(array $data)
+ {
+ if (!$this->available()) {
+ return null;
+ }
+
+ $data['position'] = (int) $this->db->select_max('position')->get('menus')->row()->position + 1;
+ $data['created_at'] = date('Y-m-d H:i:s');
+ $data['updated_at'] = date('Y-m-d H:i:s');
+
+ return $this->db->insert('menus', $data) ? (int) $this->db->insert_id() : null;
+ }
+
+ /**
+ * @param int $id
+ * @param array $data
+ */
+ public function update($id, array $data)
+ {
+ if (!$this->available()) {
+ return false;
+ }
+
+ $data['updated_at'] = date('Y-m-d H:i:s');
+
+ return (bool) $this->db->where('id', (int) $id)->update('menus', $data);
+ }
+
+ /**
+ * Removes an item; its children move up to its parent, so the sidebar
+ * never shows an orphan.
+ */
+ public function delete($id)
+ {
+ if (!$this->available()) {
+ return false;
+ }
+
+ $row = $this->find($id);
+ if ($row === null) {
+ return false;
+ }
+
+ $debug = $this->db->db_debug;
+ $this->db->db_debug = false;
+ $this->db->trans_start();
+ $this->db->where('parent_id', (int) $id)->update('menus', ['parent_id' => (int) ($row['parent_id'] ?? 0)]);
+ $this->db->where('id', (int) $id)->delete('menus');
+ $this->db->trans_complete();
+ $this->db->db_debug = $debug;
+
+ return $this->db->trans_status() !== false;
+ }
+
+ /**
+ * Rebuilds parent_id / position from the order the builder posted:
+ * a flat list of [id, depth] pairs, depth 0 = top level.
+ *
+ * @param array $order
+ *
+ * @return bool
+ */
+ public function reorder(array $order)
+ {
+ if (!$this->available() || $order === []) {
+ return false;
+ }
+
+ $known = [];
+ foreach ($this->rows() as $row) {
+ $known[(int) $row['id']] = (int) ($row['parent_id'] ?? 0);
+ }
+
+ $parents = MenuTree::parents($order, $known, self::MAX_DEPTH);
+ if ($parents === false) {
+ return false;
+ }
+
+ $debug = $this->db->db_debug;
+ $this->db->db_debug = false;
+ $this->db->trans_start();
+ $position = 0;
+ foreach ($parents as $id => $parent) {
+ $position++;
+ $this->db->where('id', $id)->update('menus', ['parent_id' => $parent, 'position' => $position, 'updated_at' => date('Y-m-d H:i:s')]);
+ }
+ $this->db->trans_complete();
+ $this->db->db_debug = $debug;
+
+ return $this->db->trans_status() !== false;
+ }
+
+ /**
+ * The rows as menu_items() wants them (label / icon / url / permission /
+ * children), ready to merge with the config menu. Inactive items, sections
+ * with nothing under them and links without a URL are skipped.
+ *
+ * @return array>
+ */
+ public function sidebar_items()
+ {
+ $rows = array_values(array_filter($this->rows(), static function ($row) {
+ return !empty($row['is_active']);
+ }));
+ if ($rows === []) {
+ return [];
+ }
+
+ $children = [];
+ $top = [];
+ foreach ($rows as $row) {
+ if ((int) ($row['parent_id'] ?? 0) === 0) {
+ $top[] = $row;
+ } else {
+ $children[(int) $row['parent_id']][] = $row;
+ }
+ }
+
+ usort($top, static function ($a, $b) {
+ return [(int) $a['position'], (int) $a['id']] <=> [(int) $b['position'], (int) $b['id']];
+ });
+
+ $build = function ($rows) use (&$build, $children) {
+ $out = [];
+ foreach ($rows as $row) {
+ if ($row['type'] === self::TYPE_SECTION) {
+ $out[] = ['section' => (string) $row['label']];
+
+ continue;
+ }
+ $isDropdown = $row['type'] === self::TYPE_DROPDOWN;
+ if (!$isDropdown && trim((string) ($row['url'] ?? '')) === '') {
+ continue;
+ }
+
+ $item = [
+ 'label' => (string) $row['label'],
+ 'url' => $isDropdown ? '' : (string) $row['url'],
+ ];
+ if (trim((string) ($row['icon'] ?? '')) !== '') {
+ $item['icon'] = (string) $row['icon'];
+ }
+ if (trim((string) ($row['permission'] ?? '')) !== '') {
+ $item['permission'] = (string) $row['permission'];
+ }
+ if (!empty($row['super_admin'])) {
+ $item['super_admin'] = true;
+ }
+
+ $kids = $build($children[(int) $row['id']] ?? []);
+ if ($isDropdown && $kids === []) {
+ continue; // an empty dropdown would be a link to nowhere
+ }
+ if ($kids !== []) {
+ $item['children'] = $kids;
+ }
+
+ $out[] = $item;
+ }
+
+ return $out;
+ };
+
+ return $build($top);
+ }
+
+ /**
+ * Whether the table exists (checked once; false before the migration).
+ */
+ public function available()
+ {
+ if ($this->available !== null) {
+ return $this->available;
+ }
+
+ if (!isset($this->db) || !is_object($this->db) || empty($this->db->conn_id)) {
+ $file = is_file(APPPATH.'config/'.ENVIRONMENT.'/database.php') || is_file(APPPATH.'config/database.php');
+ if (!$file) {
+ return $this->available = false;
+ }
+ $this->load->database();
+ }
+
+ $debug = $this->db->db_debug;
+ $this->db->db_debug = false;
+ $this->available = (bool) $this->db->table_exists('menus');
+ $this->db->db_debug = $debug;
+
+ return $this->available;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function rows()
+ {
+ if (!$this->available()) {
+ return [];
+ }
+
+ $debug = $this->db->db_debug;
+ $this->db->db_debug = false;
+ $result = $this->db->order_by('position ASC, id ASC')->get('menus');
+ $this->db->db_debug = $debug;
+
+ return $result === false ? [] : $result->result_array();
+ }
+}
diff --git a/application/modules/menus/views/form.php b/application/modules/menus/views/form.php
new file mode 100644
index 0000000..76eee86
--- /dev/null
+++ b/application/modules/menus/views/form.php
@@ -0,0 +1,104 @@
+|null $item
+ * @var array $values
+ * @var array $errors
+ * @var array> $parents
+ * @var array $permissions
+ */
+$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); };
+$invalid = static function ($key) use ($errors) { return isset($errors[$key]) ? ' is-invalid' : ''; };
+$error = static function ($key) use ($errors, $e) { return isset($errors[$key]) ? ''.$e($errors[$key]).'
' : ''; };
+$type = (string) ($values['type'] ?? 'link');
+?>
+
+
+
+
+
+
+
+
Kind
+
+ >Link (goes somewhere)
+ >Dropdown (opens a list of links, has no address of its own)
+ >Section heading (a label above items)
+
+
+
To fill a dropdown, add links and choose the dropdown under "Inside". A link that has items inside it also opens them, and then its own address is not used.
+
+
+
+ Label
+
+
+
+
+
+
+
URL
+
+
+
Relative URLs stay on this site; page/your-slug opens a page made in Admin → Pages.
+
+
+
Icon
+
+
Press Choose to pick from all Bootstrap Icons, or type a class such as bi-star.
+
+
+
+
+
+
Inside
+
+ — Top level —
+
+ >
+
+
+
+
+
+
Up to three levels deep. A heading (section) is always top level.
+
+
+ Needs permission
+
+ — Anyone signed in —
+
+ >
+
+
+ (unknown)
+
+
+
+
+
+
+
+
+
+ >
+ Only for super admins
+
+
+
+
+ >
+ Show in the sidebar
+
+
+
+
+
+
+
+
+
+
diff --git a/application/modules/menus/views/index.php b/application/modules/menus/views/index.php
new file mode 100644
index 0000000..3b6b5c3
--- /dev/null
+++ b/application/modules/menus/views/index.php
@@ -0,0 +1,69 @@
+ Menus: the tree, dragged into shape with assets/js/menus.js.
+ *
+ * @var array> $items flat rows, each with 'depth'
+ * @var bool $available the menus table exists (migrations run)
+ * @var bool $can_create, $can_edit, $can_delete
+ * @var string $csrf_name, $csrf_hash
+ */
+$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); };
+?>
+
+
+ The menu table does not exist yet. Run php index.php console migrate to build it — the menu below is the file-based one.
+
+
+
+
+
+
Items from application/config/menu.php are always shown; these are added on top.
+
New item
+
+
+
+
+
+
+
No menu items yet
+
+
+
+
+
+
+
+
diff --git a/application/modules/pages/config/permissions.php b/application/modules/pages/config/permissions.php
new file mode 100644
index 0000000..0075871
--- /dev/null
+++ b/application/modules/pages/config/permissions.php
@@ -0,0 +1,22 @@
+ Pages builds the content pages served at /page/{slug}.
+*/
+
+$config['permissions']['pages'] = [
+ 'label' => 'Page builder',
+ 'icon' => 'bi-file-earmark-richtext',
+ 'permissions' => [
+ 'view' => 'See the list of pages',
+ 'create' => 'Create pages',
+ 'edit' => 'Edit pages and their layout',
+ 'delete' => 'Delete pages',
+ 'html' => 'Write raw HTML in pages (can run scripts for every visitor: give it only to people you trust as much as a developer)',
+ ],
+];
diff --git a/application/modules/pages/controllers/Pages.php b/application/modules/pages/controllers/Pages.php
new file mode 100644
index 0000000..6e03690
--- /dev/null
+++ b/application/modules/pages/controllers/Pages.php
@@ -0,0 +1,341 @@
+ Pages builds them, /page/{slug} serves them.
+ *
+ * Routes (application/config/routes.php):
+ * GET /admin/pages pages.view the list (server-side DataTable)
+ * GET /admin/pages/data pages.view its JSON rows
+ * GET|POST /admin/pages/create pages.create
+ * GET|POST /admin/pages/edit/{id} pages.edit
+ * POST /admin/pages/delete/{id} pages.delete
+ * GET /page/{slug} public published pages (a draft
+ * is a preview for editors)
+ *
+ * A page is a list of sections (hero, text, features, cta, image, html) stored
+ * as JSON; see App\Page\SectionTypes for the blocks and their rendering.
+ *
+ * Who may write what: pages.create / pages.edit build pages out of the safe blocks
+ * (text is escaped or rebuilt from an allow-list, addresses must pass Links::safe()).
+ * The raw "Custom HTML" block can run anything in a visitor's browser, so adding or
+ * changing one needs pages.html, which only super_admin has until it is granted.
+ *
+ * @property Template $template
+ * @property Page_model $page_model
+ * @property Audit $audit
+ * @property Datatable $datatable
+ * @property CI_Input $input
+ * @property CI_Output $output
+ */
+class Pages extends MX_Controller
+{
+ public function __construct()
+ {
+ parent::__construct();
+ $this->load->helper('form');
+ $this->load->model('pages/page_model', 'page_model');
+ }
+
+ // ------------------------------------------------------------------ Admin
+
+ public function index()
+ {
+ require_permission('pages.view');
+
+ $this->template
+ ->set_title('Pages')
+ ->set_subtitle('Pages built here are served at /page/{address}. A draft is only visible to editors.')
+ ->set_breadcrumbs(['Home' => '', 'Pages' => null])
+ ->render('index', [
+ 'available' => $this->page_model->available(),
+ 'can_create' => can('pages.create'),
+ ], 'admin');
+ }
+
+ /**
+ * JSON rows for the list. Only the page being shown is read (never the whole table).
+ */
+ public function data()
+ {
+ require_permission('pages.view');
+
+ $canEdit = can('pages.edit');
+ $canDelete = can('pages.delete');
+ $e = static function ($v) {
+ return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
+ };
+
+ $this->datatable
+ ->from(static function ($db) {
+ $db->from('pages p');
+ })
+ ->select('p.id, p.title, p.slug, p.status, p.meta_description, p.updated_at, p.sections')
+ ->column('title', 'p.title')
+ ->column('slug', 'p.slug')
+ ->column('sections', null)
+ ->column('status', 'p.status')
+ ->column('updated', 'p.updated_at', false)
+ ->column('actions', null)
+ ->default_order(['p.updated_at DESC', 'p.id DESC'])
+ ->respond(function ($r) use ($canEdit, $canDelete, $e) {
+ $actions = ' ';
+ if ($canEdit) {
+ $actions .= ' ';
+ }
+ if ($canDelete) {
+ $actions .= ' '.form_open('admin/pages/delete/'.(int) $r['id'], ['class' => 'd-inline', 'data-confirm' => 'Delete the page "'.$r['title'].'"?'])
+ .' '.form_close();
+ }
+
+ return [
+ 'title' => ''.$e($r['title']).' '
+ .(trim((string) $r['meta_description']) !== '' ? ''.$e($r['meta_description']).'
' : ''),
+ 'slug' => '/page/'.$e($r['slug']).'',
+ 'sections' => (string) count(SectionTypes::toArray($r['sections'] ?? '')),
+ 'status' => $r['status'] === Page_model::STATUS_PUBLISHED ? 'published ' : 'draft ',
+ 'updated' => $r['updated_at'] ? ''.$e(date('M j, Y H:i', strtotime($r['updated_at']))).' ' : '',
+ 'actions' => $actions,
+ ];
+ });
+ }
+
+ public function create()
+ {
+ require_permission('pages.create');
+
+ if ($this->input->method() === 'post') {
+ $this->save(null);
+
+ return;
+ }
+
+ $this->form(null, ['title' => '', 'slug' => '', 'status' => Page_model::STATUS_DRAFT, 'meta_description' => '', 'sections' => []], []);
+ }
+
+ public function edit($id = 0)
+ {
+ require_permission('pages.edit');
+
+ $page = $this->page_model->find((int) $id);
+ if (!$page) {
+ show_404();
+ }
+
+ if ($this->input->method() === 'post') {
+ $this->save($page);
+
+ return;
+ }
+
+ $this->form($page, $page, []);
+ }
+
+ public function delete($id = 0)
+ {
+ if ($this->input->method() !== 'post') {
+ show_404();
+ }
+ require_permission('pages.delete');
+
+ $page = $this->page_model->find((int) $id);
+ if (!$page) {
+ show_404();
+ }
+
+ if ($this->page_model->delete((int) $id)) {
+ $this->audit->deleted('pages', $page['id'], $page['title'], $page);
+ flash('success', 'Page deleted.');
+ } else {
+ flash('error', 'The page could not be deleted. Run the migrations first: php index.php console migrate');
+ }
+
+ redirect('admin/pages');
+ }
+
+ // ----------------------------------------------------------------- Public
+
+ /**
+ * GET /page/{slug} — published pages for everyone; a draft is a preview
+ * for whoever may edit pages.
+ */
+ public function view($slug = '')
+ {
+ $page = $this->page_model->find_by_slug($slug, true);
+
+ if ($page === null) {
+ // Not published: editors may preview it, everyone else gets a 404.
+ $preview = is_logged_in() && can('pages.edit') ? $this->page_model->find_by_slug($slug, false) : null;
+ if ($preview === null) {
+ show_404();
+
+ return;
+ }
+ $page = $preview;
+ $preview_note = true;
+ } else {
+ $preview_note = false;
+ }
+
+ if (!empty($preview_note)) {
+ $this->output->set_header('Cache-Control: private, no-store');
+ }
+
+ $description = trim((string) ($page['meta_description'] ?? ''));
+ if ($description !== '') {
+ $this->template->add_head(' ');
+ }
+
+ $this->template
+ ->set_title($page['title'])
+ ->set_breadcrumbs(['Home' => '', $page['title'] => null])
+ ->render('view', [
+ 'page' => $page,
+ 'content' => SectionTypes::renderAll($page['sections'], static function ($address) {
+ return Links::resolve($address, 'site_url');
+ }),
+ 'preview' => !empty($preview_note),
+ 'edit_url' => can('pages.edit') ? site_url('admin/pages/edit/'.(int) $page['id']) : null,
+ ], 'public');
+ }
+
+ // ------------------------------------------------------------- Form & save
+
+ /**
+ * @param array $page
+ * @param array $values
+ * @param array $errors
+ */
+ protected function form($page, array $values, array $errors)
+ {
+ $this->template
+ ->set_title($page === null ? 'New page' : 'Edit '.$page['title'])
+ ->set_breadcrumbs(['Home' => '', 'Pages' => 'admin/pages', ($page === null ? 'New page' : 'Edit') => null])
+ ->add_foot('')
+ ->render('form', [
+ 'page' => $page,
+ 'values' => $values,
+ 'errors' => $errors,
+ 'catalog' => [
+ 'labels' => SectionTypes::labels(),
+ 'fields' => array_combine(array_keys(SectionTypes::labels()), array_map([SectionTypes::class, 'fields'], array_keys(SectionTypes::labels()))),
+ ],
+ ], 'admin');
+ }
+
+ /**
+ * Without pages.html a page may keep the Custom HTML blocks it already has, exactly as
+ * they are, but not add or change one.
+ *
+ * @param array> $sections
+ * @param array|null $page
+ */
+ protected function html_blocks_unchanged(array $sections, $page)
+ {
+ $existing = [];
+ foreach ((array) ($page['sections'] ?? []) as $old) {
+ if (($old['type'] ?? '') === SectionTypes::HTML) {
+ $existing[] = (string) ($old['settings']['html'] ?? '');
+ }
+ }
+
+ foreach ($sections as $section) {
+ if ($section['type'] === SectionTypes::HTML && !in_array((string) ($section['settings']['html'] ?? ''), $existing, true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @param array|null $page
+ */
+ protected function save($page)
+ {
+ $input = (array) $this->input->post(null, false);
+
+ $title = trim((string) ($input['title'] ?? ''));
+ $status = ($input['status'] ?? '') === Page_model::STATUS_PUBLISHED ? Page_model::STATUS_PUBLISHED : Page_model::STATUS_DRAFT;
+
+ // The layout arrives as indexed form fields (sections[0][settings][heading])
+ // when JS renumbers them; sections_json is the fallback written by the
+ // editor when it only changed the JSON (or before JS ran).
+ $sections = null;
+ if (array_key_exists('sections', $input) && is_array($input['sections'])) {
+ $sections = array_values($input['sections']);
+ }
+ if ($sections === null) {
+ $sections = json_decode((string) ($input['sections_json'] ?? ''), true);
+ }
+ $sections = SectionTypes::normalize(is_array($sections) ? $sections : []);
+
+ $slug = $page === null ? trim((string) ($input['slug'] ?? '')) : trim((string) ($input['slug'] ?? ($page['slug'] ?? '')));
+ if ($slug !== '') {
+ $slug = Page_model::slugify($slug);
+ }
+
+ $values = [
+ 'title' => $title,
+ 'slug' => $slug,
+ 'status' => $status,
+ 'meta_description' => trim((string) ($input['meta_description'] ?? '')),
+ 'sections' => $sections,
+ ];
+
+ $errors = [];
+ if ($title === '' || mb_strlen($title) > 190) {
+ $errors['title'] = 'Enter a title of up to 190 characters.';
+ }
+ if (mb_strlen($values['meta_description']) > 300) {
+ $errors['meta_description'] = 'Keep the description under 300 characters.';
+ }
+ if ($sections === []) {
+ $errors['sections'] = 'Add at least one section to the layout.';
+ } elseif (!can('pages.html') && !$this->html_blocks_unchanged($sections, $page)) {
+ $errors['sections'] = 'Custom HTML blocks can only be added or changed by someone with the "Write raw HTML in pages" permission (pages.html).';
+ }
+
+ if ($errors === []) {
+ if ($slug === '') {
+ $slug = $this->page_model->free_slug($title, $page['id'] ?? null);
+ }
+ if ($this->page_model->slug_taken($slug, $page['id'] ?? null)) {
+ $errors['slug'] = 'That address is already used by another page.';
+ } else {
+ $values['slug'] = $slug;
+ }
+ }
+
+ if ($errors !== []) {
+ $this->form($page, $values, $errors);
+
+ return;
+ }
+
+ if ($page === null) {
+ $id = $this->page_model->create($values);
+ if ($id === null) {
+ flash('error', 'The page could not be saved. Run the migrations first: php index.php console migrate');
+ redirect('admin/pages');
+ }
+ $this->audit->created('pages', $id, $values['title'], $values + ['slug' => $slug]);
+ flash('success', 'Page created. '.site_url('page/'.$slug));
+ } else {
+ if (!$this->page_model->update((int) $page['id'], $values)) {
+ flash('error', 'The page could not be saved. Run the migrations first: php index.php console migrate');
+ redirect('admin/pages');
+ }
+ $this->audit->updated('pages', $page['id'], $values['title'], $page + ['slug' => $page['slug']], $values + ['slug' => $slug]);
+ flash('success', 'Page saved.');
+ }
+
+ redirect('admin/pages');
+ }
+}
diff --git a/application/modules/pages/models/Page_model.php b/application/modules/pages/models/Page_model.php
new file mode 100644
index 0000000..ffac2bb
--- /dev/null
+++ b/application/modules/pages/models/Page_model.php
@@ -0,0 +1,264 @@
+ Pages; served at /page/{slug}.
+ *
+ * The layout lives in `sections` as JSON (App\Page\SectionTypes). Every query
+ * is quiet: before the migration has run — or with no database at all — pages
+ * simply do not exist and the routes fall through to 404.
+ *
+ * @property CI_DB_query_builder $db
+ */
+class Page_model extends CI_Model
+{
+ public const STATUS_DRAFT = 'draft';
+ public const STATUS_PUBLISHED = 'published';
+
+ /** @var bool|null table existence, checked once per request */
+ protected $available;
+
+ /**
+ * @param int $id
+ *
+ * @return array|null
+ */
+ public function find($id)
+ {
+ return $this->one('id', (int) $id, false);
+ }
+
+ /**
+ * A page by its public slug; only published ones (or any, for a draft
+ * preview by someone who may edit pages).
+ *
+ * @param string $slug
+ * @param bool $publishedOnly
+ *
+ * @return array|null
+ */
+ public function find_by_slug($slug, $publishedOnly = true)
+ {
+ $row = $this->one('slug', (string) $slug, $publishedOnly);
+
+ return $row;
+ }
+
+ /**
+ * @param array $data (slug, title, status, sections, meta_description)
+ *
+ * @return int|null new id
+ */
+ public function create(array $data)
+ {
+ if (!$this->available()) {
+ return null;
+ }
+
+ $now = date('Y-m-d H:i:s');
+ $row = $this->clean($data) + ['created_at' => $now, 'updated_at' => $now];
+
+ return $this->quiet(function ($db) use ($row) {
+ return $db->insert('pages', $row) ? (int) $db->insert_id() : null;
+ }) ?: null;
+ }
+
+ /**
+ * @param int $id
+ * @param array $data
+ */
+ public function update($id, array $data)
+ {
+ if (!$this->available()) {
+ return false;
+ }
+
+ $row = $this->clean($data) + ['updated_at' => date('Y-m-d H:i:s')];
+
+ return (bool) $this->quiet(function ($db) use ($id, $row) {
+ return $db->where('id', (int) $id)->update('pages', $row);
+ });
+ }
+
+ /**
+ * @param int $id
+ */
+ public function delete($id)
+ {
+ if (!$this->available()) {
+ return false;
+ }
+
+ return (bool) $this->quiet(function ($db) use ($id) {
+ return $db->where('id', (int) $id)->delete('pages');
+ });
+ }
+
+ /**
+ * A slug that is free (used by the admin form when it makes one up).
+ *
+ * @param string $wanted
+ * @param int|null $ignoreId
+ *
+ * @return string
+ */
+ public function free_slug($wanted, $ignoreId = null)
+ {
+ $slug = $this->slugify($wanted);
+ if ($slug === '') {
+ $slug = 'page';
+ }
+ if (!$this->available()) {
+ return $slug;
+ }
+
+ $candidate = $slug;
+ $i = 2;
+ while ($this->slug_exists($candidate, $ignoreId)) {
+ $candidate = $slug.'-'.$i++;
+ }
+
+ return $candidate;
+ }
+
+ /**
+ * Whether another page already uses this slug.
+ *
+ * @param string $slug
+ * @param int|null $ignoreId
+ */
+ public function slug_taken($slug, $ignoreId = null)
+ {
+ return $this->slug_exists($slug, $ignoreId);
+ }
+
+ public static function slugify($text)
+ {
+ $text = strtolower(trim((string) $text));
+ $text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
+ $text = trim($text, '-');
+
+ return mb_substr($text, 0, 150);
+ }
+
+ public function available()
+ {
+ if ($this->available !== null) {
+ return $this->available;
+ }
+
+ if (!isset($this->db) || !is_object($this->db) || empty($this->db->conn_id)) {
+ $file = is_file(APPPATH.'config/'.ENVIRONMENT.'/database.php') || is_file(APPPATH.'config/database.php');
+ if (!$file) {
+ return $this->available = false;
+ }
+ $this->load->database();
+ }
+
+ $debug = $this->db->db_debug;
+ $this->db->db_debug = false;
+ $this->available = (bool) $this->db->table_exists('pages');
+ $this->db->db_debug = $debug;
+
+ return $this->available;
+ }
+
+ // ------------------------------------------------------------------
+ // Internals
+ // ------------------------------------------------------------------
+
+ /**
+ * @param string $column
+ * @param mixed $value
+ * @param bool $publishedOnly
+ *
+ * @return array|null
+ */
+ protected function one($column, $value, $publishedOnly)
+ {
+ if (!$this->available()) {
+ return null;
+ }
+
+ $row = $this->quiet(function ($db) use ($column, $value, $publishedOnly) {
+ if ($publishedOnly) {
+ $db->where('status', self::STATUS_PUBLISHED);
+ }
+
+ return $db->where($column, $value)->limit(1)->get('pages')->row_array();
+ });
+
+ if (!is_array($row) || $row === []) {
+ return null;
+ }
+ $row['sections'] = SectionTypes::toArray($row['sections'] ?? '');
+
+ return $row;
+ }
+
+ /**
+ * @param array $data
+ *
+ * @return array
+ */
+ protected function clean(array $data)
+ {
+ $status = ($data['status'] ?? '') === self::STATUS_PUBLISHED ? self::STATUS_PUBLISHED : self::STATUS_DRAFT;
+
+ return [
+ 'slug' => mb_substr((string) ($data['slug'] ?? ''), 0, 150),
+ 'title' => mb_substr(trim((string) ($data['title'] ?? '')), 0, 190),
+ 'status' => $status,
+ 'sections' => SectionTypes::encode($data['sections'] ?? []),
+ 'meta_description' => mb_substr(trim((string) ($data['meta_description'] ?? '')), 0, 300),
+ ];
+ }
+
+ /**
+ * @param string $slug
+ * @param int|null $ignoreId
+ */
+ protected function slug_exists($slug, $ignoreId = null)
+ {
+ $found = $this->quiet(function ($db) use ($slug, $ignoreId) {
+ if ($ignoreId) {
+ $db->where('id !=', (int) $ignoreId);
+ }
+
+ return $db->where('slug', $slug)->limit(1)->count_all_results('pages') > 0;
+ });
+
+ return (bool) $found;
+ }
+
+ /**
+ * Runs $fn with db_debug off, so a missing table or unreachable server
+ * returns instead of taking the page down.
+ *
+ * @param callable $fn
+ *
+ * @return mixed
+ */
+ protected function quiet($fn)
+ {
+ if (!$this->available()) {
+ return null;
+ }
+
+ $debug = $this->db->db_debug;
+ $this->db->db_debug = false;
+
+ try {
+ return $fn($this->db);
+ } catch (\Throwable $e) {
+ return null;
+ } finally {
+ $this->db->db_debug = $debug;
+ }
+ }
+}
diff --git a/application/modules/pages/views/form.php b/application/modules/pages/views/form.php
new file mode 100644
index 0000000..c102916
--- /dev/null
+++ b/application/modules/pages/views/form.php
@@ -0,0 +1,143 @@
+|null $page
+ * @var array $values
+ * @var array $errors
+ * @var array{labels: array, fields: array>>} $catalog
+ */
+$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); };
+$invalid = static function ($key) use ($errors) { return isset($errors[$key]) ? ' is-invalid' : ''; };
+$error = static function ($key) use ($errors, $e) { return isset($errors[$key]) ? ''.$e($errors[$key]).'
' : ''; };
+
+/** One setting as a form field. */
+$field = static function (array $f, $index, $value, $e) {
+ $name = 'sections['.(int) $index.'][settings]['.$f['name'].']';
+ $help = !empty($f['help']) ? ''.$e($f['help']).'
' : '';
+ $label = ''.$e($f['label']).' ';
+
+ if ($f['type'] === 'textarea') {
+ $rows = (int) ($f['rows'] ?? 4);
+
+ return ''.$label
+ .''.$help.'
';
+ }
+ if ($f['type'] === 'color') {
+ $v = preg_match('/^#[0-9a-fA-F]{3,8}$/', (string) $value) ? (string) $value : '';
+
+ return ''.$label
+ .' '.$help.'
';
+ }
+
+ return ''.$label
+ .' '.$help.'
';
+};
+
+$section_card = static function ($index, $type, array $settings, $labels, $fields, $field, $e) {
+ $out = '';
+ $out .= '
';
+ foreach ($fields[$type] ?? [] as $f) {
+ $out .= $field($f, $index, (string) ($settings[$f['name']] ?? ''), $e);
+ }
+ $out .= '
';
+
+ return $out;
+};
+?>
+
+ Please fix the following:
+
+
+
+
+ 'page-form']) ?>
+
+
+
+
+
+ Title
+
+
+
+
+ Status
+
+ >Draft (only editors see it)
+ >Published
+
+
+
+
Address
+
+ /page/
+
+
+
+
Leave empty to build one from the title.
+
+
+
Search description
+
+
+
Shown by Google below the link. Optional.
+
+
+
+
+
+
+
+
+
+
+ $section) {
+ echo $section_card($i, (string) ($section['type'] ?? ''), (array) ($section['settings'] ?? []), $catalog['labels'], $catalog['fields'], $field, $e);
+ }
+ ?>
+
+
>
+ No sections yet — pick a type above and press “Add section”.
+
+
Adding, moving and removing sections needs JavaScript; the fields above still save as they are.
+
+
+
+
+
+
+
diff --git a/application/modules/pages/views/index.php b/application/modules/pages/views/index.php
new file mode 100644
index 0000000..5ebb658
--- /dev/null
+++ b/application/modules/pages/views/index.php
@@ -0,0 +1,33 @@
+ Pages: the list (a server-side DataTable, see Pages::data()).
+ *
+ * @var bool $available the pages table exists (migrations run)
+ * @var bool $can_create
+ */
+$columns = [
+ ['Title', ['priority' => 1]],
+ ['Address', ['priority' => 3]],
+ ['Sections', ['priority' => 5, 'orderable' => false, 'class' => 'text-center']],
+ ['Status', ['priority' => 2]],
+ ['Updated', ['priority' => 4, 'orderable' => true]],
+ ['', ['priority' => 1, 'orderable' => false, 'class' => 'text-end text-nowrap']],
+];
+?>
+
+
+ The pages table does not exist yet. Run php index.php console migrate to build it.
+
+
+
+
+
Every page lives at /page/{address} and can be linked from Admin → Menu builder.
+
+
New page
+
+
+
+
+
+ [4, 'desc'], 'empty' => 'No pages yet.']) ?>
+
+
diff --git a/application/modules/pages/views/view.php b/application/modules/pages/views/view.php
new file mode 100644
index 0000000..3711c23
--- /dev/null
+++ b/application/modules/pages/views/view.php
@@ -0,0 +1,24 @@
+ $page
+ * @var string $content SectionTypes::renderAll()
+ * @var bool $preview a draft being previewed by an editor
+ * @var string|null $edit_url
+ */
+$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); };
+?>
+
+
+
+
+
+
+
+
+
+ This page has no sections yet.
+
diff --git a/application/src/Menus/MenuTree.php b/application/src/Menus/MenuTree.php
new file mode 100644
index 0000000..284fed0
--- /dev/null
+++ b/application/src/Menus/MenuTree.php
@@ -0,0 +1,134 @@
+ $order posted as [id, depth]
+ * @param array $known id => current parent_id, i.e. every row that must appear
+ * @param int $maxDepth deepest nesting allowed
+ *
+ * @return array|false id => parent id, or false when the order is unusable
+ * (unknown/duplicate id, missing row, impossible depth)
+ */
+ public static function parents($order, array $known, $maxDepth = 2)
+ {
+ if (!is_array($order) || $order === [] || $known === []) {
+ return false;
+ }
+
+ $parents = [];
+ $stack = []; // depth => id: the ancestor on duty at each level
+
+ foreach ($order as $pair) {
+ if (!is_array($pair) || !isset($pair[0], $pair[1])) {
+ return false;
+ }
+
+ $id = (int) $pair[0];
+ $depth = (int) $pair[1];
+
+ if (!isset($known[$id]) || array_key_exists($id, $parents)) {
+ return false; // unknown or repeated
+ }
+ if ($depth < 0 || $depth > $maxDepth) {
+ return false;
+ }
+
+ $parent = 0;
+ for ($d = $depth - 1; $d >= 0; $d--) {
+ if (isset($stack[$d])) {
+ $parent = (int) $stack[$d];
+ break;
+ }
+ }
+ if ($parent === $id) {
+ return false;
+ }
+
+ $parents[$id] = $parent;
+ $stack[$depth] = $id;
+ unset($stack[$depth + 1], $stack[$depth + 2]);
+ }
+
+ if (count($parents) !== count($known)) {
+ return false; // only part of the menu was sent
+ }
+
+ // No item may end up inside its own subtree (a cycle would hide it).
+ foreach ($parents as $id => $parent) {
+ $seen = [$id];
+ while ($parent > 0) {
+ if (in_array($parent, $seen, true)) {
+ return false;
+ }
+ $seen[] = $parent;
+ $parent = (int) ($parents[$parent] ?? 0);
+ }
+ }
+
+ return $parents;
+ }
+
+ /**
+ * Whether an item may be given a new parent without breaking the menu.
+ *
+ * Refused when the parent is the item itself or one of its own descendants (a loop
+ * would hide them), does not exist, or would push the item's subtree below $maxDepth.
+ *
+ * @param array $parents id => parent id (0 = top level) of every item
+ * @param int $id the item being moved/edited
+ * @param int $newParent 0 for the top level
+ */
+ public static function canNest(array $parents, $id, $newParent, $maxDepth = 2)
+ {
+ $id = (int) $id;
+ $newParent = (int) $newParent;
+
+ if ($newParent === 0) {
+ return true;
+ }
+ if ($newParent === $id || !isset($parents[$newParent])) {
+ return false;
+ }
+
+ // Depth of the new parent (top level = 0); a loop in the stored data counts as unusable.
+ $depth = 0;
+ $seen = [$newParent];
+ for ($p = (int) $parents[$newParent]; $p > 0; $p = (int) ($parents[$p] ?? 0)) {
+ if ($p === $id || in_array($p, $seen, true)) {
+ return false; // the parent sits inside the item's own subtree
+ }
+ $seen[] = $p;
+ ++$depth;
+ }
+
+ // Levels below the item itself (a new item, id 0, has none; top-level rows have parent 0 too).
+ $height = 0;
+ $level = $id > 0 ? [$id] : [];
+ while ($level !== []) {
+ $next = [];
+ foreach ($parents as $child => $parent) {
+ if (in_array((int) $parent, $level, true) && (int) $child !== $id) {
+ $next[] = (int) $child;
+ }
+ }
+ if ($next !== []) {
+ ++$height;
+ }
+ $level = count($next) > 50 ? [] : $next; // a menu is small; stop on nonsense
+ }
+
+ return ($depth + 1) + $height <= $maxDepth;
+ }
+}
diff --git a/application/src/Page/Html.php b/application/src/Page/Html.php
new file mode 100644
index 0000000..e6283ad
--- /dev/null
+++ b/application/src/Page/Html.php
@@ -0,0 +1,113 @@
+hi` becomes `hi`);
+ * script, style, iframe, object, embed, svg, math, form, template and their kind
+ * are removed with everything inside;
+ * - no attribute survives except `href` on links, which must pass Links::safe();
+ * links get rel="noopener noreferrer";
+ * - comments, processing instructions and CDATA are dropped.
+ *
+ * (strip_tags() with an allow-list keeps every attribute of the allowed tags,
+ * so `` and ` ` would pass.)
+ */
+final class Html
+{
+ private const KEEP = ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'code', 'pre', 'span', 'hr', 'a'];
+ private const DROP_WITH_CONTENT = ['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'svg', 'math', 'form', 'template', 'noscript', 'textarea', 'select', 'option', 'button', 'input', 'link', 'meta', 'base', 'title', 'head', 'audio', 'video', 'canvas', 'dialog', 'portal', 'xmp', 'plaintext', 'noembed', 'noframes'];
+
+ /**
+ * @param callable(string): ?string|null $href maps a link's address to the one to print, or null to drop it
+ */
+ public static function sanitize($html, $href = null)
+ {
+ $html = (string) $html;
+ if (trim($html) === '') {
+ return '';
+ }
+
+ $doc = new DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+ $doc->loadHTML(''.$html.'
', LIBXML_NONET | LIBXML_NOWARNING | LIBXML_NOERROR);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+
+ $root = $doc->getElementById('root');
+ if (!$root instanceof DOMElement) {
+ return htmlspecialchars(strip_tags($html), ENT_QUOTES, 'UTF-8');
+ }
+
+ $href = $href ?: [Links::class, 'safe'];
+ self::clean($root, $href);
+
+ $out = '';
+ foreach (iterator_to_array($root->childNodes) as $child) {
+ $out .= $doc->saveHTML($child);
+ }
+
+ return trim($out);
+ }
+
+ /**
+ * @param callable(string): ?string $href
+ */
+ private static function clean(DOMNode $parent, $href)
+ {
+ foreach (iterator_to_array($parent->childNodes) as $node) {
+ if ($node->nodeType === XML_TEXT_NODE) {
+ continue;
+ }
+
+ if (!$node instanceof DOMElement) {
+ $parent->removeChild($node); // comments, processing instructions, CDATA...
+
+ continue;
+ }
+
+ $name = strtolower($node->nodeName);
+
+ if (in_array($name, self::DROP_WITH_CONTENT, true)) {
+ $parent->removeChild($node);
+
+ continue;
+ }
+
+ self::clean($node, $href);
+
+ if (!in_array($name, self::KEEP, true)) {
+ // Unknown element: keep what is inside it, lose the element.
+ while ($node->firstChild) {
+ $parent->insertBefore($node->firstChild, $node);
+ }
+ $parent->removeChild($node);
+
+ continue;
+ }
+
+ $wanted = null;
+ if ($name === 'a' && $node->hasAttribute('href')) {
+ $wanted = $href(html_entity_decode($node->getAttribute('href'), ENT_QUOTES, 'UTF-8'));
+ }
+
+ foreach (iterator_to_array($node->attributes) as $attribute) {
+ $node->removeAttribute($attribute->nodeName);
+ }
+
+ if ($name === 'a' && is_string($wanted) && $wanted !== '') {
+ $node->setAttribute('href', $wanted);
+ $node->setAttribute('rel', 'noopener noreferrer');
+ }
+ }
+ }
+}
diff --git a/application/src/Page/Links.php b/application/src/Page/Links.php
new file mode 100644
index 0000000..4eb1251
--- /dev/null
+++ b/application/src/Page/Links.php
@@ -0,0 +1,85 @@
+ 2000) {
+ return null;
+ }
+ // Whitespace or control characters inside an address are how filters get fooled
+ // (browsers ignore tabs and newlines in "java\tscript:"), and a backslash is read as a slash.
+ if (preg_match('/[\x00-\x20\x7f\\\\]/', $url)) {
+ return null;
+ }
+ // "//host/path" would leave this site without saying so.
+ if (strpos($url, '//') === 0) {
+ return null;
+ }
+
+ if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', $url, $m)) {
+ if (!in_array(strtolower($m[1]), self::SCHEMES, true)) {
+ return null;
+ }
+ if (in_array(strtolower($m[1]), ['http', 'https'], true)) {
+ $host = parse_url($url, PHP_URL_HOST);
+ if (!is_string($host) || $host === '') {
+ return null;
+ }
+ }
+
+ return $url;
+ }
+
+ return $url;
+ }
+
+ /**
+ * Whether a (safe) address already says where it goes, as opposed to a page of this site.
+ */
+ public static function isAbsolute($url)
+ {
+ return (bool) preg_match('/^(https?|mailto|tel):/i', (string) $url);
+ }
+
+ /**
+ * The address to put in the page: absolute ones as they are, pages of this site through $site
+ * (normally site_url).
+ *
+ * @param callable(string): string $site
+ *
+ * @return string|null null when the address must not be used
+ */
+ public static function resolve($url, callable $site)
+ {
+ $safe = self::safe($url);
+ if ($safe === null) {
+ return null;
+ }
+ if (self::isAbsolute($safe)) {
+ return $safe;
+ }
+
+ return $safe[0] === '#' ? $safe : $site(ltrim($safe, '/'));
+ }
+}
diff --git a/application/src/Page/SectionTypes.php b/application/src/Page/SectionTypes.php
new file mode 100644
index 0000000..e536cfc
--- /dev/null
+++ b/application/src/Page/SectionTypes.php
@@ -0,0 +1,320 @@
+ Pages), as pure PHP: which types
+ * exist, what each one asks for, how a stored layout is cleaned up, and how it
+ * is rendered on the public page. Unit-tested in tests-app/Unit/PageSectionsTest.php.
+ *
+ * A page's `sections` column holds a JSON list:
+ *
+ * [{"type": "hero", "settings": {"heading": "Hello", ...}}, ...]
+ *
+ * Everything except the "html" block type escapes its values, so a layout
+ * authored in the admin cannot inject markup into the public page by accident:
+ * text is escaped, the body of a text block is rebuilt from an allow-list (Html),
+ * and every link or image address must pass Links::safe(). The "html" block is
+ * raw on purpose and is only for people with the `pages.html` permission.
+ */
+final class SectionTypes
+{
+ public const HERO = 'hero';
+ public const TEXT = 'text';
+ public const FEATURES = 'features';
+ public const CTA = 'cta';
+ public const IMAGE = 'image';
+ public const HTML = 'html';
+
+ /** Most blocks a page may hold (keeps one page inside a TEXT column). */
+ public const MAX_SECTIONS = 40;
+
+ /** Longest single setting value; longer text is cut by normalize(). */
+ public const MAX_VALUE = 20000;
+
+ /**
+ * @return array type => label for the builder's dropdown
+ */
+ public static function labels()
+ {
+ return [
+ self::HERO => 'Hero (title + button)',
+ self::TEXT => 'Text block',
+ self::FEATURES => 'Features (cards)',
+ self::CTA => 'Call to action',
+ self::IMAGE => 'Image',
+ self::HTML => 'Custom HTML',
+ ];
+ }
+
+ public static function exists($type)
+ {
+ return isset(self::labels()[$type]);
+ }
+
+ /**
+ * The settings one block type asks for, in order.
+ *
+ * Each entry: label, type (text | textarea | color), optional help.
+ *
+ * @return array>
+ */
+ public static function fields($type)
+ {
+ switch ($type) {
+ case self::HERO:
+ return [
+ ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'],
+ ['name' => 'subheading', 'label' => 'Description', 'type' => 'textarea', 'rows' => 3],
+ ['name' => 'button_label', 'label' => 'Button label', 'type' => 'text', 'help' => 'Leave empty for no button.'],
+ ['name' => 'button_url', 'label' => 'Button link', 'type' => 'text', 'help' => 'e.g. register, page/about or https://example.com'],
+ ['name' => 'background', 'label' => 'Background color', 'type' => 'color'],
+ ];
+
+ case self::TEXT:
+ return [
+ ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'],
+ ['name' => 'body', 'label' => 'Body', 'type' => 'textarea', 'rows' => 8,
+ 'help' => 'One paragraph per line. Links, bold, lists and headings in the HTML are kept.'],
+ ];
+
+ case self::FEATURES:
+ return [
+ ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'],
+ ['name' => 'items', 'label' => 'Cards', 'type' => 'textarea', 'rows' => 8,
+ 'help' => 'One card per line: Title | Short description'],
+ ];
+
+ case self::CTA:
+ return [
+ ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'],
+ ['name' => 'text', 'label' => 'Text', 'type' => 'textarea', 'rows' => 3],
+ ['name' => 'button_label', 'label' => 'Button label', 'type' => 'text'],
+ ['name' => 'button_url', 'label' => 'Button link', 'type' => 'text'],
+ ['name' => 'background', 'label' => 'Background color', 'type' => 'color'],
+ ];
+
+ case self::IMAGE:
+ return [
+ ['name' => 'url', 'label' => 'Image URL or path', 'type' => 'text',
+ 'help' => 'e.g. uploads/hero.jpg or https://…'],
+ ['name' => 'alt', 'label' => 'Alt text', 'type' => 'text'],
+ ['name' => 'caption', 'label' => 'Caption', 'type' => 'text'],
+ ];
+
+ default: // self::HTML
+ return [
+ ['name' => 'html', 'label' => 'HTML', 'type' => 'textarea', 'rows' => 10,
+ 'help' => 'Rendered as-is on the public page. Only give this to people who write HTML.'],
+ ];
+ }
+ }
+
+ /**
+ * Cleans what was posted in the builder: unknown blocks are dropped,
+ * values are trimmed strings, and an empty layout comes back empty.
+ *
+ * @param array $sections
+ *
+ * @return array}>
+ */
+ public static function normalize($sections)
+ {
+ if (!is_array($sections)) {
+ return [];
+ }
+
+ $out = [];
+ foreach (array_slice(array_values($sections), 0, self::MAX_SECTIONS) as $section) {
+ if (!is_array($section)) {
+ continue;
+ }
+ $type = (string) ($section['type'] ?? '');
+ if (!self::exists($type)) {
+ continue;
+ }
+
+ $known = array_column(self::fields($type), 'name');
+ $settings = [];
+ foreach ((array) ($section['settings'] ?? []) as $key => $value) {
+ if (!in_array((string) $key, $known, true)) {
+ continue;
+ }
+ $value = is_scalar($value) ? trim((string) $value) : '';
+ if ($value !== '') {
+ $settings[(string) $key] = self::cut($value);
+ }
+ }
+
+ $out[] = ['type' => $type, 'settings' => $settings];
+ }
+
+ return $out;
+ }
+
+ /**
+ * @param array $sections
+ */
+ public static function toArray($sections)
+ {
+ return self::normalize(is_array($sections) ? $sections : json_decode((string) $sections, true));
+ }
+
+ /**
+ * @param array $sections
+ */
+ public static function encode($sections)
+ {
+ return (string) json_encode(self::normalize($sections), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ /**
+ * One block as HTML for the public page.
+ *
+ * @param array $section
+ * @param callable(string): ?string|null $url turns a link/image address into the one to print (null = drop it);
+ * defaults to Links::safe() with pages of this site left relative
+ */
+ public static function render(array $section, $url = null)
+ {
+ $url = $url ?: [Links::class, 'safe'];
+ $type = (string) ($section['type'] ?? '');
+ $s = (array) ($section['settings'] ?? []);
+
+ switch ($type) {
+ case self::HERO:
+ $style = self::background($s['background'] ?? '');
+
+ return ''
+ .''
+ .(!empty($s['heading']) ? '
'.self::text($s['heading']).' ' : '')
+ .(!empty($s['subheading']) ? '
'.nl2br(self::text($s['subheading'])).'
' : '')
+ .self::button($s, 'btn btn-primary', $url)
+ .'
';
+
+ case self::TEXT:
+ return ''
+ .(!empty($s['heading']) ? '
'.self::text($s['heading']).' ' : '')
+ .(!empty($s['body']) ? '
'.self::body($s['body'], $url).'
' : '')
+ .'
';
+
+ case self::FEATURES:
+ $items = self::pairs($s['items'] ?? '');
+ $html = ''
+ .(!empty($s['heading']) ? '
'.self::text($s['heading']).' ' : '');
+ if ($items !== []) {
+ $html .= '
';
+ foreach ($items as [$title, $text]) {
+ $html .= '
'
+ .'
'.self::text($title).' '
+ .($text !== '' ? '
'.self::text($text).'
' : '')
+ .'
';
+ }
+ $html .= '
';
+ }
+ return $html.'
';
+
+ case self::CTA:
+ $style = self::background($s['background'] ?? '', 'var(--bs-primary)');
+
+ return ''
+ .(!empty($s['heading']) ? '
'.self::text($s['heading']).' ' : '')
+ .(!empty($s['text']) ? '
'.self::text($s['text']).'
' : '')
+ .self::button($s, 'btn btn-light', $url)
+ .'
';
+
+ case self::IMAGE:
+ $src = empty($s['url']) ? null : $url((string) $s['url']);
+ if ($src === null || $src === '') {
+ return '';
+ }
+ $img = ' ';
+
+ return ''
+ .(!empty($s['caption']) ? ''.$img.''.self::text($s['caption']).' ' : $img)
+ .'
';
+
+ default: // self::HTML
+ // Deliberate: this block type is raw HTML, written by an admin.
+ return (string) ($s['html'] ?? '');
+ }
+ }
+
+ /**
+ * A whole layout as HTML.
+ *
+ * @param array $sections
+ * @param callable(string): ?string|null $url see render()
+ */
+ public static function renderAll($sections, $url = null)
+ {
+ $html = '';
+ foreach (self::normalize($sections) as $section) {
+ $html .= self::render($section, $url);
+ }
+
+ return $html;
+ }
+
+ // ------------------------------------------------------------------
+ // Internals
+ // ------------------------------------------------------------------
+
+ private static function text($value)
+ {
+ return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
+ }
+
+ /** Body text: rebuilt from an allow-list (see Html), paragraphs come from blank lines. */
+ private static function body($value, $url)
+ {
+ $html = Html::sanitize((string) $value, $url);
+ $html = preg_replace('/\s*\R\s*\R\s*/', "\n", $html) ?? $html;
+
+ return '
'.trim($html).'
';
+ }
+
+ private static function button(array $s, $class, $url)
+ {
+ if (empty($s['button_label']) || empty($s['button_url'])) {
+ return '';
+ }
+ $href = $url((string) $s['button_url']);
+ if ($href === null || $href === '') {
+ return '';
+ }
+
+ return ''.self::text($s['button_label']).' ';
+ }
+
+ /** "Title | description" lines as pairs. */
+ private static function pairs($text)
+ {
+ $out = [];
+ foreach (preg_split('/\R/', (string) $text) ?: [] as $line) {
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+ $parts = array_map('trim', explode('|', $line, 2));
+ $out[] = [$parts[0], $parts[1] ?? ''];
+ }
+
+ return $out;
+ }
+
+ private static function background($color, $fallback = '')
+ {
+ $color = (string) $color;
+ if (!preg_match('/^#[0-9a-fA-F]{3,8}$/', $color)) {
+ return $fallback !== '' ? 'background:'.$fallback : '';
+ }
+
+ return 'background:'.$color;
+ }
+
+ private static function cut($value)
+ {
+ return mb_strlen($value) > self::MAX_VALUE ? mb_substr($value, 0, self::MAX_VALUE) : $value;
+ }
+}
diff --git a/application/views/layouts/admin.php b/application/views/layouts/admin.php
index d70fea6..72a49f5 100644
--- a/application/views/layouts/admin.php
+++ b/application/views/layouts/admin.php
@@ -29,7 +29,9 @@
continue;
}
- echo ''.$icon.$label.' ';
+ $href = menu_href($item['url'] ?? '');
+ $external = \App\Page\Links::isAbsolute((string) ($item['url'] ?? '')); // as typed: pages of this site are resolved to full addresses too
+ echo ''.$icon.$label.' ';
}
};
?>
diff --git a/assets/css/app.css b/assets/css/app.css
index 4f66adc..8f7377d 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -559,6 +559,14 @@ table.app-datatable > tbody > tr.child span.dtr-data { flex: 1 1 10rem; min-widt
.crud-checklist { max-height: 12rem; overflow-y: auto; }
.g-field .card-header { background: var(--bs-tertiary-bg); }
+/* Icon picker (assets/js/icon-picker.js) */
+.icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.2rem, 1fr)); gap: .4rem; max-height: min(55vh, 28rem); overflow-y: auto; padding: .15rem; }
+.icon-pick { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .3rem; min-height: 4.4rem; padding: .5rem .25rem; border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius); background: var(--bs-body-bg); color: var(--bs-body-color); }
+.icon-pick .bi { font-size: 1.5rem; line-height: 1; }
+.icon-pick span { font-size: .68rem; line-height: 1.1; max-width: 100%; overflow: hidden; text-overflow: ellipsis; color: var(--bs-secondary-color); }
+.icon-pick:hover, .icon-pick:focus-visible { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .08); }
+.icon-pick.is-selected { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .16); }
+
/* Auth pages: "or" divider and the Google button */
.auth-or { display: flex; align-items: center; gap: .75rem; margin: 1.25rem 0; color: var(--bs-secondary-color); font-size: .85rem; }
.auth-or::before, .auth-or::after { content: ""; flex: 1; border-top: 1px solid var(--bs-border-color); }
diff --git a/assets/js/app.js b/assets/js/app.js
index 772e95d..4bdabca 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -51,7 +51,7 @@
var toast = el.getAttribute('data-confirm-toast');
if (toast) { var t = toast.split('|'); window.App.toast(t[0], t.slice(1).join('|')); }
else if (el.tagName === 'A') { window.location.href = el.href; }
- else if (el.form) { el.form.removeAttribute('data-confirm'); el.form.requestSubmit ? el.form.requestSubmit(el) : el.form.submit(); }
+ else if (el.form) { el.form.removeAttribute('data-confirm'); el.form.requestSubmit ? el.form.requestSubmit(el.type === 'submit' ? el : undefined) : el.form.submit(); }
});
});
document.addEventListener('submit', function (e) {
diff --git a/assets/js/datatables-init.js b/assets/js/datatables-init.js
index bcc2219..a94caf6 100644
--- a/assets/js/datatables-init.js
+++ b/assets/js/datatables-init.js
@@ -14,7 +14,7 @@
var narrow = window.matchMedia('(max-width: 575.98px)').matches;
document.querySelectorAll('table[data-datatable]').forEach(function (el) {
- var order = (el.getAttribute('data-order') || '').split(',');
+ var order = (el.getAttribute('data-initial-order') || '').split(',');
var columns = Array.prototype.map.call(el.querySelectorAll('thead th'), function (th) {
return {
orderable: th.getAttribute('data-orderable') !== 'false',
diff --git a/assets/js/icon-picker.js b/assets/js/icon-picker.js
new file mode 100644
index 0000000..6de4854
--- /dev/null
+++ b/assets/js/icon-picker.js
@@ -0,0 +1,165 @@
+/*
+ * Icon picker (assets/js/icon-picker.js)
+ *
+ * Turns into an input with a live preview and a
+ * "Choose" button that opens a searchable grid of every Bootstrap Icons icon. Picking one writes
+ * its class (bi-house) into the input and fires `input`/`change`, so other scripts react as if it
+ * had been typed. The field stays a normal text input: typing a class by hand still works.
+ *
+ * The list (about 2,000 names) is fetched once, the first time a picker is opened, and drawn in
+ * chunks of 150 as you scroll, so the page stays light.
+ */
+(function () {
+ 'use strict';
+
+ var inputs = document.querySelectorAll('input[data-icon-picker]');
+ if (!inputs.length || !window.bootstrap) { return; }
+
+ var CHUNK = 150;
+ var names = null; // all icon names, without the "bi-" prefix
+ var loading = null; // the fetch promise
+ var modalEl = null, modal = null, searchEl, gridEl, countEl, statusEl;
+ var target = null; // the input being filled
+ var matches = [], shown = 0;
+
+ function load(url) {
+ if (names) { return Promise.resolve(names); }
+ if (!loading) {
+ loading = fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
+ .then(function (r) { if (!r.ok) { throw new Error(r.status); } return r.json(); })
+ .then(function (list) { names = Array.isArray(list) ? list : []; return names; })
+ .catch(function (e) { loading = null; throw e; });
+ }
+ return loading;
+ }
+
+ function current() {
+ var v = target ? target.value.trim() : '';
+ return /^bi-[a-z0-9-]+$/.test(v) ? v.slice(3) : '';
+ }
+
+ function draw(reset) {
+ if (reset) { gridEl.innerHTML = ''; shown = 0; gridEl.scrollTop = 0; }
+ var selected = current();
+ var html = '';
+ var end = Math.min(matches.length, shown + CHUNK);
+ for (var i = shown; i < end; i++) {
+ var n = matches[i];
+ html += '' +
+ '' + n + ' ';
+ }
+ gridEl.insertAdjacentHTML('beforeend', html);
+ shown = end;
+ countEl.textContent = matches.length + (matches.length === 1 ? ' icon' : ' icons');
+ statusEl.textContent = matches.length === 0 ? 'No icon matches that search.' : '';
+ }
+
+ function filter() {
+ var words = searchEl.value.toLowerCase().split(/\s+/).filter(Boolean);
+ matches = (names || []).filter(function (n) {
+ return words.every(function (w) { return n.indexOf(w) !== -1; });
+ });
+ draw(true);
+ }
+
+ function build() {
+ if (modalEl) { return; }
+ modalEl = document.createElement('div');
+ modalEl.className = 'modal fade';
+ modalEl.tabIndex = -1;
+ modalEl.setAttribute('aria-labelledby', 'icon-picker-title');
+ modalEl.setAttribute('aria-hidden', 'true');
+ modalEl.innerHTML =
+ '';
+ document.body.appendChild(modalEl);
+ modal = new bootstrap.Modal(modalEl);
+ searchEl = modalEl.querySelector('input[type=search]');
+ gridEl = modalEl.querySelector('.icon-grid');
+ countEl = modalEl.querySelector('.icon-count');
+ statusEl = modalEl.querySelector('.icon-status');
+
+ var timer = null;
+ searchEl.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(filter, 120); });
+ gridEl.addEventListener('scroll', function () {
+ if (shown < matches.length && gridEl.scrollTop + gridEl.clientHeight > gridEl.scrollHeight - 200) { draw(false); }
+ });
+ gridEl.addEventListener('click', function (e) {
+ var b = e.target.closest('.icon-pick');
+ if (b) { choose('bi-' + b.getAttribute('data-name')); }
+ });
+ modalEl.querySelector('.icon-clear').addEventListener('click', function () { choose(''); });
+ modalEl.addEventListener('shown.bs.modal', function () { searchEl.focus(); });
+ }
+
+ function choose(value) {
+ if (target) {
+ target.value = value;
+ target.dispatchEvent(new Event('input', { bubbles: true }));
+ target.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ modal.hide();
+ if (target) { target.focus(); }
+ }
+
+ function open(input) {
+ build();
+ target = input;
+ searchEl.value = '';
+ statusEl.textContent = '';
+ countEl.textContent = 'Loading…';
+ gridEl.innerHTML = '';
+ modal.show();
+ load(input.getAttribute('data-icon-picker')).then(function () {
+ filter();
+ // The icon in use may be far down the list: draw up to it so it can be shown and highlighted.
+ var at = matches.indexOf(current());
+ while (at >= shown && shown < matches.length) { draw(false); }
+ var sel = gridEl.querySelector('.is-selected');
+ if (sel) { sel.scrollIntoView({ block: 'center' }); }
+ }).catch(function () {
+ countEl.textContent = '';
+ statusEl.textContent = 'The icon list could not be loaded. Type the class name (for example bi-house) instead.';
+ });
+ }
+
+ Array.prototype.forEach.call(inputs, function (input) {
+ var group = input.parentElement && input.parentElement.classList.contains('input-group') ? input.parentElement : null;
+ var preview = null;
+
+ if (!group) {
+ group = document.createElement('div');
+ group.className = 'input-group';
+ input.parentNode.insertBefore(group, input);
+ preview = document.createElement('span');
+ preview.className = 'input-group-text';
+ preview.innerHTML = ' ';
+ group.appendChild(preview);
+ group.appendChild(input);
+ }
+
+ var button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'btn btn-outline-secondary';
+ button.innerHTML = ' Choose';
+ button.addEventListener('click', function () { open(input); });
+ input.insertAdjacentElement('afterend', button);
+
+ function sync() {
+ if (!preview) { return; }
+ var v = input.value.trim();
+ preview.firstChild.className = 'bi ' + (/^bi-[a-z0-9-]+$/.test(v) ? v : 'bi-image');
+ }
+ input.addEventListener('input', sync);
+ sync();
+ });
+})();
diff --git a/assets/js/menu-form.js b/assets/js/menu-form.js
new file mode 100644
index 0000000..cbe5b31
--- /dev/null
+++ b/assets/js/menu-form.js
@@ -0,0 +1,23 @@
+/*
+ * Admin -> Menu builder, the add/edit form (assets/js/menu-form.js)
+ *
+ * Shows only the fields that matter for the chosen kind: a link has an address, a dropdown has
+ * none (it only opens its items), a section heading has neither an address, an icon nor a parent.
+ */
+(function () {
+ 'use strict';
+
+ var kind = document.getElementById('m_type');
+ if (!kind) { return; }
+
+ function update() {
+ var current = kind.value;
+ document.querySelectorAll('[data-show-for]').forEach(function (el) {
+ var visible = el.getAttribute('data-show-for').split(/\s+/).indexOf(current) !== -1;
+ el.hidden = !visible;
+ });
+ }
+
+ kind.addEventListener('change', update);
+ update();
+})();
diff --git a/assets/js/menus.js b/assets/js/menus.js
new file mode 100644
index 0000000..8f3422b
--- /dev/null
+++ b/assets/js/menus.js
@@ -0,0 +1,210 @@
+/*
+ * Admin -> Menus (assets/js/menus.js)
+ *
+ * Drags the sidebar rows into a new order, including nesting: dropping in the
+ * middle of a row makes the dragged item its child, dropping on the top or
+ * bottom third inserts it before/after that row at the same level. The ↑/↓
+ * buttons do the same without dragging. Either way the finished order is sent
+ * as [[id, depth], ...] to admin/menus/reorder and saved server-side.
+ */
+(function () {
+ 'use strict';
+
+ var list = document.getElementById('menu-builder');
+ if (!list) return;
+
+ var status = document.getElementById('menu-builder-status');
+ var readonly = list.hasAttribute('data-readonly');
+ var indent = 24;
+ var maxDepth = 2;
+ var dragged = null;
+ var saved = null;
+
+ function rows() {
+ return Array.prototype.slice.call(list.querySelectorAll('.menu-row'));
+ }
+
+ function depthOf(row) {
+ return parseInt(row.getAttribute('data-depth') || '0', 10);
+ }
+
+ function setDepth(row, depth) {
+ depth = Math.max(0, Math.min(maxDepth, depth));
+ row.setAttribute('data-depth', depth);
+ row.style.marginInlineStart = (depth * indent) + 'px';
+ }
+
+ function paint() {
+ rows().forEach(function (row) {
+ setDepth(row, depthOf(row));
+ });
+ }
+
+ /** The row plus everything nested under it. */
+ function subtree(row) {
+ var out = [row];
+ var depth = depthOf(row);
+ var next = row.nextElementSibling;
+ while (next && next.classList.contains('menu-row') && depthOf(next) > depth) {
+ out.push(next);
+ next = next.nextElementSibling;
+ }
+ return out;
+ }
+
+ /** Whether `ancestor` contains `row` (dropping a parent into its own child). */
+ function isAncestor(ancestor, row) {
+ var depth = depthOf(row);
+ var node = row;
+ while (node && node !== list) {
+ if (node === ancestor) return true;
+ node = node.previousElementSibling;
+ while (node && depthOf(node) >= depth && node !== ancestor) {
+ node = node.previousElementSibling;
+ }
+ depth = node ? depthOf(node) : -1;
+ if (node === ancestor) return true;
+ if (!node || depth < 0) return false;
+ }
+ return false;
+ }
+
+ function message(text, isError) {
+ if (!status) return;
+ status.textContent = text;
+ status.classList.toggle('text-danger', !!isError);
+ status.classList.toggle('text-body-secondary', !isError);
+ }
+
+ function order() {
+ return rows().map(function (row) {
+ return [parseInt(row.getAttribute('data-id'), 10), depthOf(row)];
+ });
+ }
+
+ function save() {
+ var next = JSON.stringify(order());
+ if (next === saved) return;
+ if (readonly) return;
+ saved = next;
+
+ var token = document.getElementById('menu-csrf-hash');
+ var name = document.getElementById('menu-csrf-name');
+ var body = new URLSearchParams();
+ body.set(name ? name.value : 'csrf_token', token ? token.value : '');
+ body.set('order', next);
+
+ message('Saving…');
+ fetch(list.getAttribute('data-reorder-url'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
+ body: body.toString()
+ }).then(function (r) {
+ return r.json().then(function (data) { return { ok: r.ok && data.ok, data: data }; });
+ }).then(function (res) {
+ message(res.data.message || (res.ok ? 'Menu saved.' : 'Could not save.'), !res.ok);
+ if (res.ok) saved = JSON.stringify(order());
+ }).catch(function () {
+ message('Could not save the menu (network error).', true);
+ saved = null;
+ });
+ }
+
+ // ---------------------------------------------------------------- Drag
+ rows().forEach(function (row) {
+ row.setAttribute('draggable', readonly ? 'false' : 'true');
+
+ row.addEventListener('dragstart', function (e) {
+ dragged = row;
+ row.classList.add('dragging');
+ if (e.dataTransfer) {
+ e.dataTransfer.effectAllowed = 'move';
+ try { e.dataTransfer.setData('text/plain', row.getAttribute('data-id')); } catch (err) {}
+ }
+ });
+
+ row.addEventListener('dragend', function () {
+ row.classList.remove('dragging');
+ dragged = null;
+ save();
+ });
+
+ row.addEventListener('dragover', function (e) {
+ if (!dragged || dragged === row || isAncestor(dragged, row)) return;
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
+
+ var box = row.getBoundingClientRect();
+ var y = (e.clientY - box.top) / box.height;
+ var depth = depthOf(row);
+
+ if (y > 0.33 && y < 0.67) {
+ // Onto the row: it becomes the parent.
+ row.after(dragged);
+ setDepth(dragged, depth + 1);
+ } else if (y <= 0.33) {
+ row.before(dragged);
+ setDepth(dragged, depth);
+ } else {
+ var block = subtree(row);
+ block[block.length - 1].after(dragged);
+ setDepth(dragged, depth);
+ }
+ });
+
+ row.addEventListener('drop', function (e) {
+ e.preventDefault();
+ });
+ });
+
+ list.addEventListener('dragover', function (e) {
+ if (dragged && e.target === list) e.preventDefault();
+ });
+ list.addEventListener('drop', function (e) {
+ if (dragged && e.target === list) {
+ e.preventDefault();
+ list.appendChild(dragged);
+ setDepth(dragged, 0);
+ save();
+ }
+ });
+
+ // ------------------------------------------------------------- Buttons
+ list.addEventListener('click', function (e) {
+ var btn = e.target.closest ? e.target.closest('.menu-up, .menu-down') : null;
+ if (!btn || readonly) return;
+
+ var row = btn.closest('.menu-row');
+ if (!row) return;
+
+ if (btn.classList.contains('menu-up')) {
+ var prev = row.previousElementSibling;
+ while (prev && !prev.classList.contains('menu-row')) prev = prev.previousElementSibling;
+ if (prev) {
+ var block = subtree(prev);
+ block[0].before.apply(block[0], subtree(row));
+ if (depthOf(prev) !== depthOf(row)) setDepth(row, depthOf(prev));
+ }
+ } else {
+ var next = row.nextElementSibling;
+ while (next && !next.classList.contains('menu-row')) next = next.nextElementSibling;
+ if (next) {
+ var mine = subtree(row);
+ // Nesting deeper with ↓ when the next row is deeper already.
+ if (depthOf(next) > depthOf(row)) {
+ setDepth(row, depthOf(next));
+ next.after(mine[0]);
+ } else {
+ subtree(next)[subtree(next).length - 1].after.apply(mine[0], mine);
+ setDepth(row, depthOf(next));
+ }
+ }
+ }
+
+ paint();
+ save();
+ });
+
+ paint();
+ saved = JSON.stringify(order());
+})();
diff --git a/assets/js/pages.js b/assets/js/pages.js
new file mode 100644
index 0000000..d3fec6b
--- /dev/null
+++ b/assets/js/pages.js
@@ -0,0 +1,118 @@
+/*
+ * Admin -> Pages (assets/js/pages.js)
+ *
+ * Adds, moves and removes the section cards in the layout editor. Every card
+ * is plain form fields (sections[0][settings][heading]); after any change the
+ * indexes are renumbered so the server receives an ordered list. The markup
+ * mirrors the server-rendered cards in application/modules/pages/views/form.php.
+ */
+(function () {
+ 'use strict';
+
+ var host = document.getElementById('page-sections');
+ var form = document.getElementById('page-form');
+ if (!host || !form) return;
+
+ var catalogEl = document.getElementById('page-catalog');
+ var catalog = { labels: {}, fields: {} };
+ try { catalog = JSON.parse(catalogEl.textContent); } catch (e) {}
+
+ var empty = document.getElementById('page-sections-empty');
+
+ function esc(value) {
+ return String(value == null ? '' : value)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ function fieldHtml(f, index, value) {
+ var name = 'sections[' + index + '][settings][' + f.name + ']';
+ var help = f.help ? '' + esc(f.help) + '
' : '';
+ var label = '' + esc(f.label) + ' ';
+
+ if (f.type === 'textarea') {
+ return '' + label +
+ '' + help + '
';
+ }
+ if (f.type === 'color') {
+ return '' + label +
+ ' ' + help + '
';
+ }
+ return '' + label +
+ ' ' + help + '
';
+ }
+
+ function cardHtml(type, index) {
+ var label = catalog.labels[type] || type;
+ var fields = (catalog.fields && catalog.fields[type]) || [];
+
+ var html = '' +
+ '' +
+ '
';
+
+ fields.forEach(function (f) {
+ html += fieldHtml(f, index, '');
+ });
+
+ return html + '
';
+ }
+
+ function cards() {
+ return Array.prototype.slice.call(host.querySelectorAll('.page-section'));
+ }
+
+ function renumber() {
+ cards().forEach(function (card, i) {
+ card.setAttribute('data-index', i);
+ var type = card.querySelector('[data-role="type"]');
+ if (type) type.name = 'sections[' + i + '][type]';
+ card.querySelectorAll('[data-field]').forEach(function (input) {
+ input.name = 'sections[' + i + '][settings][' + input.getAttribute('data-field') + ']';
+ });
+ });
+ if (empty) empty.hidden = cards().length > 0;
+ }
+
+ var add = document.getElementById('page-add');
+ var typeSelect = document.getElementById('page-add-type');
+ if (add && typeSelect) {
+ add.addEventListener('click', function () {
+ var type = typeSelect.value;
+ host.insertAdjacentHTML('beforeend', cardHtml(type, cards().length));
+ renumber();
+ var last = host.lastElementChild;
+ if (last) {
+ var first = last.querySelector('input, textarea');
+ if (first) first.focus();
+ }
+ });
+ }
+
+ host.addEventListener('click', function (e) {
+ var btn = e.target.closest ? e.target.closest('.section-up, .section-down, .section-remove') : null;
+ if (!btn) return;
+ var card = btn.closest('.page-section');
+ if (!card) return;
+
+ if (btn.classList.contains('section-remove')) {
+ card.remove();
+ } else if (btn.classList.contains('section-up') && card.previousElementSibling) {
+ host.insertBefore(card, card.previousElementSibling);
+ } else if (btn.classList.contains('section-down') && card.nextElementSibling) {
+ host.insertBefore(card.nextElementSibling, card);
+ }
+
+ renumber();
+ });
+
+ form.addEventListener('submit', renumber);
+ renumber();
+})();
diff --git a/assets/vendor/bootstrap-icons/icons.json b/assets/vendor/bootstrap-icons/icons.json
new file mode 100644
index 0000000..ada6312
--- /dev/null
+++ b/assets/vendor/bootstrap-icons/icons.json
@@ -0,0 +1 @@
+["0-circle","0-circle-fill","0-square","0-square-fill","1-circle","1-circle-fill","1-square","1-square-fill","123","2-circle","2-circle-fill","2-square","2-square-fill","3-circle","3-circle-fill","3-square","3-square-fill","4-circle","4-circle-fill","4-square","4-square-fill","5-circle","5-circle-fill","5-square","5-square-fill","6-circle","6-circle-fill","6-square","6-square-fill","7-circle","7-circle-fill","7-square","7-square-fill","8-circle","8-circle-fill","8-square","8-square-fill","9-circle","9-circle-fill","9-square","9-square-fill","activity","airplane","airplane-engines","airplane-engines-fill","airplane-fill","alarm","alarm-fill","alexa","align-bottom","align-center","align-end","align-middle","align-start","align-top","alipay","alphabet","alphabet-uppercase","alt","amazon","amd","android","android2","app","app-indicator","apple","archive","archive-fill","arrow-90deg-down","arrow-90deg-left","arrow-90deg-right","arrow-90deg-up","arrow-bar-down","arrow-bar-left","arrow-bar-right","arrow-bar-up","arrow-clockwise","arrow-counterclockwise","arrow-down","arrow-down-circle","arrow-down-circle-fill","arrow-down-left","arrow-down-left-circle","arrow-down-left-circle-fill","arrow-down-left-square","arrow-down-left-square-fill","arrow-down-right","arrow-down-right-circle","arrow-down-right-circle-fill","arrow-down-right-square","arrow-down-right-square-fill","arrow-down-short","arrow-down-square","arrow-down-square-fill","arrow-down-up","arrow-left","arrow-left-circle","arrow-left-circle-fill","arrow-left-right","arrow-left-short","arrow-left-square","arrow-left-square-fill","arrow-repeat","arrow-return-left","arrow-return-right","arrow-right","arrow-right-circle","arrow-right-circle-fill","arrow-right-short","arrow-right-square","arrow-right-square-fill","arrow-through-heart","arrow-through-heart-fill","arrow-up","arrow-up-circle","arrow-up-circle-fill","arrow-up-left","arrow-up-left-circle","arrow-up-left-circle-fill","arrow-up-left-square","arrow-up-left-square-fill","arrow-up-right","arrow-up-right-circle","arrow-up-right-circle-fill","arrow-up-right-square","arrow-up-right-square-fill","arrow-up-short","arrow-up-square","arrow-up-square-fill","arrows","arrows-angle-contract","arrows-angle-expand","arrows-collapse","arrows-collapse-vertical","arrows-expand","arrows-expand-vertical","arrows-fullscreen","arrows-move","arrows-vertical","aspect-ratio","aspect-ratio-fill","asterisk","at","award","award-fill","back","backpack","backpack-fill","backpack2","backpack2-fill","backpack3","backpack3-fill","backpack4","backpack4-fill","backspace","backspace-fill","backspace-reverse","backspace-reverse-fill","badge-3d","badge-3d-fill","badge-4k","badge-4k-fill","badge-8k","badge-8k-fill","badge-ad","badge-ad-fill","badge-ar","badge-ar-fill","badge-cc","badge-cc-fill","badge-hd","badge-hd-fill","badge-sd","badge-sd-fill","badge-tm","badge-tm-fill","badge-vo","badge-vo-fill","badge-vr","badge-vr-fill","badge-wc","badge-wc-fill","bag","bag-check","bag-check-fill","bag-dash","bag-dash-fill","bag-fill","bag-heart","bag-heart-fill","bag-plus","bag-plus-fill","bag-x","bag-x-fill","balloon","balloon-fill","balloon-heart","balloon-heart-fill","ban","ban-fill","bandaid","bandaid-fill","bank","bank2","bar-chart","bar-chart-fill","bar-chart-line","bar-chart-line-fill","bar-chart-steps","basket","basket-fill","basket2","basket2-fill","basket3","basket3-fill","battery","battery-charging","battery-full","battery-half","behance","bell","bell-fill","bell-slash","bell-slash-fill","bezier","bezier2","bicycle","bing","binoculars","binoculars-fill","blockquote-left","blockquote-right","bluetooth","body-text","book","book-fill","book-half","bookmark","bookmark-check","bookmark-check-fill","bookmark-dash","bookmark-dash-fill","bookmark-fill","bookmark-heart","bookmark-heart-fill","bookmark-plus","bookmark-plus-fill","bookmark-star","bookmark-star-fill","bookmark-x","bookmark-x-fill","bookmarks","bookmarks-fill","bookshelf","boombox","boombox-fill","bootstrap","bootstrap-fill","bootstrap-reboot","border","border-all","border-bottom","border-center","border-inner","border-left","border-middle","border-outer","border-right","border-style","border-top","border-width","bounding-box","bounding-box-circles","box","box-arrow-down","box-arrow-down-left","box-arrow-down-right","box-arrow-in-down","box-arrow-in-down-left","box-arrow-in-down-right","box-arrow-in-left","box-arrow-in-right","box-arrow-in-up","box-arrow-in-up-left","box-arrow-in-up-right","box-arrow-left","box-arrow-right","box-arrow-up","box-arrow-up-left","box-arrow-up-right","box-fill","box-seam","box-seam-fill","box2","box2-fill","box2-heart","box2-heart-fill","boxes","braces","braces-asterisk","bricks","briefcase","briefcase-fill","brightness-alt-high","brightness-alt-high-fill","brightness-alt-low","brightness-alt-low-fill","brightness-high","brightness-high-fill","brightness-low","brightness-low-fill","brilliance","broadcast","broadcast-pin","browser-chrome","browser-edge","browser-firefox","browser-safari","brush","brush-fill","bucket","bucket-fill","bug","bug-fill","building","building-add","building-check","building-dash","building-down","building-exclamation","building-fill","building-fill-add","building-fill-check","building-fill-dash","building-fill-down","building-fill-exclamation","building-fill-gear","building-fill-lock","building-fill-slash","building-fill-up","building-fill-x","building-gear","building-lock","building-slash","building-up","building-x","buildings","buildings-fill","bullseye","bus-front","bus-front-fill","c-circle","c-circle-fill","c-square","c-square-fill","cake","cake-fill","cake2","cake2-fill","calculator","calculator-fill","calendar","calendar-check","calendar-check-fill","calendar-date","calendar-date-fill","calendar-day","calendar-day-fill","calendar-event","calendar-event-fill","calendar-fill","calendar-heart","calendar-heart-fill","calendar-minus","calendar-minus-fill","calendar-month","calendar-month-fill","calendar-plus","calendar-plus-fill","calendar-range","calendar-range-fill","calendar-week","calendar-week-fill","calendar-x","calendar-x-fill","calendar2","calendar2-check","calendar2-check-fill","calendar2-date","calendar2-date-fill","calendar2-day","calendar2-day-fill","calendar2-event","calendar2-event-fill","calendar2-fill","calendar2-heart","calendar2-heart-fill","calendar2-minus","calendar2-minus-fill","calendar2-month","calendar2-month-fill","calendar2-plus","calendar2-plus-fill","calendar2-range","calendar2-range-fill","calendar2-week","calendar2-week-fill","calendar2-x","calendar2-x-fill","calendar3","calendar3-event","calendar3-event-fill","calendar3-fill","calendar3-range","calendar3-range-fill","calendar3-week","calendar3-week-fill","calendar4","calendar4-event","calendar4-range","calendar4-week","camera","camera-fill","camera-reels","camera-reels-fill","camera-video","camera-video-fill","camera-video-off","camera-video-off-fill","camera2","capslock","capslock-fill","capsule","capsule-pill","car-front","car-front-fill","card-checklist","card-heading","card-image","card-list","card-text","caret-down","caret-down-fill","caret-down-square","caret-down-square-fill","caret-left","caret-left-fill","caret-left-square","caret-left-square-fill","caret-right","caret-right-fill","caret-right-square","caret-right-square-fill","caret-up","caret-up-fill","caret-up-square","caret-up-square-fill","cart","cart-check","cart-check-fill","cart-dash","cart-dash-fill","cart-fill","cart-plus","cart-plus-fill","cart-x","cart-x-fill","cart2","cart3","cart4","cash","cash-coin","cash-stack","cassette","cassette-fill","cast","cc-circle","cc-circle-fill","cc-square","cc-square-fill","chat","chat-dots","chat-dots-fill","chat-fill","chat-heart","chat-heart-fill","chat-left","chat-left-dots","chat-left-dots-fill","chat-left-fill","chat-left-heart","chat-left-heart-fill","chat-left-quote","chat-left-quote-fill","chat-left-text","chat-left-text-fill","chat-quote","chat-quote-fill","chat-right","chat-right-dots","chat-right-dots-fill","chat-right-fill","chat-right-heart","chat-right-heart-fill","chat-right-quote","chat-right-quote-fill","chat-right-text","chat-right-text-fill","chat-square","chat-square-dots","chat-square-dots-fill","chat-square-fill","chat-square-heart","chat-square-heart-fill","chat-square-quote","chat-square-quote-fill","chat-square-text","chat-square-text-fill","chat-text","chat-text-fill","check","check-all","check-circle","check-circle-fill","check-lg","check-square","check-square-fill","check2","check2-all","check2-circle","check2-square","chevron-bar-contract","chevron-bar-down","chevron-bar-expand","chevron-bar-left","chevron-bar-right","chevron-bar-up","chevron-compact-down","chevron-compact-left","chevron-compact-right","chevron-compact-up","chevron-contract","chevron-double-down","chevron-double-left","chevron-double-right","chevron-double-up","chevron-down","chevron-expand","chevron-left","chevron-right","chevron-up","circle","circle-fill","circle-half","circle-square","clipboard","clipboard-check","clipboard-check-fill","clipboard-data","clipboard-data-fill","clipboard-fill","clipboard-heart","clipboard-heart-fill","clipboard-minus","clipboard-minus-fill","clipboard-plus","clipboard-plus-fill","clipboard-pulse","clipboard-x","clipboard-x-fill","clipboard2","clipboard2-check","clipboard2-check-fill","clipboard2-data","clipboard2-data-fill","clipboard2-fill","clipboard2-heart","clipboard2-heart-fill","clipboard2-minus","clipboard2-minus-fill","clipboard2-plus","clipboard2-plus-fill","clipboard2-pulse","clipboard2-pulse-fill","clipboard2-x","clipboard2-x-fill","clock","clock-fill","clock-history","cloud","cloud-arrow-down","cloud-arrow-down-fill","cloud-arrow-up","cloud-arrow-up-fill","cloud-check","cloud-check-fill","cloud-download","cloud-download-fill","cloud-drizzle","cloud-drizzle-fill","cloud-fill","cloud-fog","cloud-fog-fill","cloud-fog2","cloud-fog2-fill","cloud-hail","cloud-hail-fill","cloud-haze","cloud-haze-fill","cloud-haze2","cloud-haze2-fill","cloud-lightning","cloud-lightning-fill","cloud-lightning-rain","cloud-lightning-rain-fill","cloud-minus","cloud-minus-fill","cloud-moon","cloud-moon-fill","cloud-plus","cloud-plus-fill","cloud-rain","cloud-rain-fill","cloud-rain-heavy","cloud-rain-heavy-fill","cloud-slash","cloud-slash-fill","cloud-sleet","cloud-sleet-fill","cloud-snow","cloud-snow-fill","cloud-sun","cloud-sun-fill","cloud-upload","cloud-upload-fill","clouds","clouds-fill","cloudy","cloudy-fill","code","code-slash","code-square","coin","collection","collection-fill","collection-play","collection-play-fill","columns","columns-gap","command","compass","compass-fill","cone","cone-striped","controller","cookie","copy","cpu","cpu-fill","credit-card","credit-card-2-back","credit-card-2-back-fill","credit-card-2-front","credit-card-2-front-fill","credit-card-fill","crop","crosshair","crosshair2","cup","cup-fill","cup-hot","cup-hot-fill","cup-straw","currency-bitcoin","currency-dollar","currency-euro","currency-exchange","currency-pound","currency-rupee","currency-yen","cursor","cursor-fill","cursor-text","dash","dash-circle","dash-circle-dotted","dash-circle-fill","dash-lg","dash-square","dash-square-dotted","dash-square-fill","database","database-add","database-check","database-dash","database-down","database-exclamation","database-fill","database-fill-add","database-fill-check","database-fill-dash","database-fill-down","database-fill-exclamation","database-fill-gear","database-fill-lock","database-fill-slash","database-fill-up","database-fill-x","database-gear","database-lock","database-slash","database-up","database-x","device-hdd","device-hdd-fill","device-ssd","device-ssd-fill","diagram-2","diagram-2-fill","diagram-3","diagram-3-fill","diamond","diamond-fill","diamond-half","dice-1","dice-1-fill","dice-2","dice-2-fill","dice-3","dice-3-fill","dice-4","dice-4-fill","dice-5","dice-5-fill","dice-6","dice-6-fill","disc","disc-fill","discord","display","display-fill","displayport","displayport-fill","distribute-horizontal","distribute-vertical","door-closed","door-closed-fill","door-open","door-open-fill","dot","download","dpad","dpad-fill","dribbble","dropbox","droplet","droplet-fill","droplet-half","duffle","duffle-fill","ear","ear-fill","earbuds","easel","easel-fill","easel2","easel2-fill","easel3","easel3-fill","egg","egg-fill","egg-fried","eject","eject-fill","emoji-angry","emoji-angry-fill","emoji-astonished","emoji-astonished-fill","emoji-dizzy","emoji-dizzy-fill","emoji-expressionless","emoji-expressionless-fill","emoji-frown","emoji-frown-fill","emoji-grimace","emoji-grimace-fill","emoji-grin","emoji-grin-fill","emoji-heart-eyes","emoji-heart-eyes-fill","emoji-kiss","emoji-kiss-fill","emoji-laughing","emoji-laughing-fill","emoji-neutral","emoji-neutral-fill","emoji-smile","emoji-smile-fill","emoji-smile-upside-down","emoji-smile-upside-down-fill","emoji-sunglasses","emoji-sunglasses-fill","emoji-surprise","emoji-surprise-fill","emoji-tear","emoji-tear-fill","emoji-wink","emoji-wink-fill","envelope","envelope-arrow-down","envelope-arrow-down-fill","envelope-arrow-up","envelope-arrow-up-fill","envelope-at","envelope-at-fill","envelope-check","envelope-check-fill","envelope-dash","envelope-dash-fill","envelope-exclamation","envelope-exclamation-fill","envelope-fill","envelope-heart","envelope-heart-fill","envelope-open","envelope-open-fill","envelope-open-heart","envelope-open-heart-fill","envelope-paper","envelope-paper-fill","envelope-paper-heart","envelope-paper-heart-fill","envelope-plus","envelope-plus-fill","envelope-slash","envelope-slash-fill","envelope-x","envelope-x-fill","eraser","eraser-fill","escape","ethernet","ev-front","ev-front-fill","ev-station","ev-station-fill","exclamation","exclamation-circle","exclamation-circle-fill","exclamation-diamond","exclamation-diamond-fill","exclamation-lg","exclamation-octagon","exclamation-octagon-fill","exclamation-square","exclamation-square-fill","exclamation-triangle","exclamation-triangle-fill","exclude","explicit","explicit-fill","exposure","eye","eye-fill","eye-slash","eye-slash-fill","eyedropper","eyeglasses","facebook","fan","fast-forward","fast-forward-btn","fast-forward-btn-fill","fast-forward-circle","fast-forward-circle-fill","fast-forward-fill","feather","feather2","file","file-arrow-down","file-arrow-down-fill","file-arrow-up","file-arrow-up-fill","file-bar-graph","file-bar-graph-fill","file-binary","file-binary-fill","file-break","file-break-fill","file-check","file-check-fill","file-code","file-code-fill","file-diff","file-diff-fill","file-earmark","file-earmark-arrow-down","file-earmark-arrow-down-fill","file-earmark-arrow-up","file-earmark-arrow-up-fill","file-earmark-bar-graph","file-earmark-bar-graph-fill","file-earmark-binary","file-earmark-binary-fill","file-earmark-break","file-earmark-break-fill","file-earmark-check","file-earmark-check-fill","file-earmark-code","file-earmark-code-fill","file-earmark-diff","file-earmark-diff-fill","file-earmark-easel","file-earmark-easel-fill","file-earmark-excel","file-earmark-excel-fill","file-earmark-fill","file-earmark-font","file-earmark-font-fill","file-earmark-image","file-earmark-image-fill","file-earmark-lock","file-earmark-lock-fill","file-earmark-lock2","file-earmark-lock2-fill","file-earmark-medical","file-earmark-medical-fill","file-earmark-minus","file-earmark-minus-fill","file-earmark-music","file-earmark-music-fill","file-earmark-pdf","file-earmark-pdf-fill","file-earmark-person","file-earmark-person-fill","file-earmark-play","file-earmark-play-fill","file-earmark-plus","file-earmark-plus-fill","file-earmark-post","file-earmark-post-fill","file-earmark-ppt","file-earmark-ppt-fill","file-earmark-richtext","file-earmark-richtext-fill","file-earmark-ruled","file-earmark-ruled-fill","file-earmark-slides","file-earmark-slides-fill","file-earmark-spreadsheet","file-earmark-spreadsheet-fill","file-earmark-text","file-earmark-text-fill","file-earmark-word","file-earmark-word-fill","file-earmark-x","file-earmark-x-fill","file-earmark-zip","file-earmark-zip-fill","file-easel","file-easel-fill","file-excel","file-excel-fill","file-fill","file-font","file-font-fill","file-image","file-image-fill","file-lock","file-lock-fill","file-lock2","file-lock2-fill","file-medical","file-medical-fill","file-minus","file-minus-fill","file-music","file-music-fill","file-pdf","file-pdf-fill","file-person","file-person-fill","file-play","file-play-fill","file-plus","file-plus-fill","file-post","file-post-fill","file-ppt","file-ppt-fill","file-richtext","file-richtext-fill","file-ruled","file-ruled-fill","file-slides","file-slides-fill","file-spreadsheet","file-spreadsheet-fill","file-text","file-text-fill","file-word","file-word-fill","file-x","file-x-fill","file-zip","file-zip-fill","files","files-alt","filetype-aac","filetype-ai","filetype-bmp","filetype-cs","filetype-css","filetype-csv","filetype-doc","filetype-docx","filetype-exe","filetype-gif","filetype-heic","filetype-html","filetype-java","filetype-jpg","filetype-js","filetype-json","filetype-jsx","filetype-key","filetype-m4p","filetype-md","filetype-mdx","filetype-mov","filetype-mp3","filetype-mp4","filetype-otf","filetype-pdf","filetype-php","filetype-png","filetype-ppt","filetype-pptx","filetype-psd","filetype-py","filetype-raw","filetype-rb","filetype-sass","filetype-scss","filetype-sh","filetype-sql","filetype-svg","filetype-tiff","filetype-tsx","filetype-ttf","filetype-txt","filetype-wav","filetype-woff","filetype-xls","filetype-xlsx","filetype-xml","filetype-yml","film","filter","filter-circle","filter-circle-fill","filter-left","filter-right","filter-square","filter-square-fill","fingerprint","fire","flag","flag-fill","floppy","floppy-fill","floppy2","floppy2-fill","flower1","flower2","flower3","folder","folder-check","folder-fill","folder-minus","folder-plus","folder-symlink","folder-symlink-fill","folder-x","folder2","folder2-open","fonts","forward","forward-fill","front","fuel-pump","fuel-pump-diesel","fuel-pump-diesel-fill","fuel-pump-fill","fullscreen","fullscreen-exit","funnel","funnel-fill","gear","gear-fill","gear-wide","gear-wide-connected","gem","gender-ambiguous","gender-female","gender-male","gender-neuter","gender-trans","geo","geo-alt","geo-alt-fill","geo-fill","gift","gift-fill","git","github","gitlab","globe","globe-americas","globe-asia-australia","globe-central-south-asia","globe-europe-africa","globe2","google","google-play","gpu-card","graph-down","graph-down-arrow","graph-up","graph-up-arrow","grid","grid-1x2","grid-1x2-fill","grid-3x2","grid-3x2-gap","grid-3x2-gap-fill","grid-3x3","grid-3x3-gap","grid-3x3-gap-fill","grid-fill","grip-horizontal","grip-vertical","h-circle","h-circle-fill","h-square","h-square-fill","hammer","hand-index","hand-index-fill","hand-index-thumb","hand-index-thumb-fill","hand-thumbs-down","hand-thumbs-down-fill","hand-thumbs-up","hand-thumbs-up-fill","handbag","handbag-fill","hash","hdd","hdd-fill","hdd-network","hdd-network-fill","hdd-rack","hdd-rack-fill","hdd-stack","hdd-stack-fill","hdmi","hdmi-fill","headphones","headset","headset-vr","heart","heart-arrow","heart-fill","heart-half","heart-pulse","heart-pulse-fill","heartbreak","heartbreak-fill","hearts","heptagon","heptagon-fill","heptagon-half","hexagon","hexagon-fill","hexagon-half","highlighter","highlights","hospital","hospital-fill","hourglass","hourglass-bottom","hourglass-split","hourglass-top","house","house-add","house-add-fill","house-check","house-check-fill","house-dash","house-dash-fill","house-door","house-door-fill","house-down","house-down-fill","house-exclamation","house-exclamation-fill","house-fill","house-gear","house-gear-fill","house-heart","house-heart-fill","house-lock","house-lock-fill","house-slash","house-slash-fill","house-up","house-up-fill","house-x","house-x-fill","houses","houses-fill","hr","hurricane","hypnotize","image","image-alt","image-fill","images","inbox","inbox-fill","inboxes","inboxes-fill","incognito","indent","infinity","info","info-circle","info-circle-fill","info-lg","info-square","info-square-fill","input-cursor","input-cursor-text","instagram","intersect","journal","journal-album","journal-arrow-down","journal-arrow-up","journal-bookmark","journal-bookmark-fill","journal-check","journal-code","journal-medical","journal-minus","journal-plus","journal-richtext","journal-text","journal-x","journals","joystick","justify","justify-left","justify-right","kanban","kanban-fill","key","key-fill","keyboard","keyboard-fill","ladder","lamp","lamp-fill","laptop","laptop-fill","layer-backward","layer-forward","layers","layers-fill","layers-half","layout-sidebar","layout-sidebar-inset","layout-sidebar-inset-reverse","layout-sidebar-reverse","layout-split","layout-text-sidebar","layout-text-sidebar-reverse","layout-text-window","layout-text-window-reverse","layout-three-columns","layout-wtf","life-preserver","lightbulb","lightbulb-fill","lightbulb-off","lightbulb-off-fill","lightning","lightning-charge","lightning-charge-fill","lightning-fill","line","link","link-45deg","linkedin","list","list-check","list-columns","list-columns-reverse","list-nested","list-ol","list-stars","list-task","list-ul","lock","lock-fill","luggage","luggage-fill","lungs","lungs-fill","magic","magnet","magnet-fill","mailbox","mailbox-flag","mailbox2","mailbox2-flag","map","map-fill","markdown","markdown-fill","marker-tip","mask","mastodon","medium","megaphone","megaphone-fill","memory","menu-app","menu-app-fill","menu-button","menu-button-fill","menu-button-wide","menu-button-wide-fill","menu-down","menu-up","messenger","meta","mic","mic-fill","mic-mute","mic-mute-fill","microsoft","microsoft-teams","minecart","minecart-loaded","modem","modem-fill","moisture","moon","moon-fill","moon-stars","moon-stars-fill","mortarboard","mortarboard-fill","motherboard","motherboard-fill","mouse","mouse-fill","mouse2","mouse2-fill","mouse3","mouse3-fill","music-note","music-note-beamed","music-note-list","music-player","music-player-fill","newspaper","nintendo-switch","node-minus","node-minus-fill","node-plus","node-plus-fill","noise-reduction","nut","nut-fill","nvidia","nvme","nvme-fill","octagon","octagon-fill","octagon-half","opencollective","optical-audio","optical-audio-fill","option","outlet","p-circle","p-circle-fill","p-square","p-square-fill","paint-bucket","palette","palette-fill","palette2","paperclip","paragraph","pass","pass-fill","passport","passport-fill","patch-check","patch-check-fill","patch-exclamation","patch-exclamation-fill","patch-minus","patch-minus-fill","patch-plus","patch-plus-fill","patch-question","patch-question-fill","pause","pause-btn","pause-btn-fill","pause-circle","pause-circle-fill","pause-fill","paypal","pc","pc-display","pc-display-horizontal","pc-horizontal","pci-card","pci-card-network","pci-card-sound","peace","peace-fill","pen","pen-fill","pencil","pencil-fill","pencil-square","pentagon","pentagon-fill","pentagon-half","people","people-fill","percent","person","person-add","person-arms-up","person-badge","person-badge-fill","person-bounding-box","person-check","person-check-fill","person-circle","person-dash","person-dash-fill","person-down","person-exclamation","person-fill","person-fill-add","person-fill-check","person-fill-dash","person-fill-down","person-fill-exclamation","person-fill-gear","person-fill-lock","person-fill-slash","person-fill-up","person-fill-x","person-gear","person-heart","person-hearts","person-lines-fill","person-lock","person-plus","person-plus-fill","person-raised-hand","person-rolodex","person-slash","person-square","person-standing","person-standing-dress","person-up","person-vcard","person-vcard-fill","person-video","person-video2","person-video3","person-walking","person-wheelchair","person-workspace","person-x","person-x-fill","phone","phone-fill","phone-flip","phone-landscape","phone-landscape-fill","phone-vibrate","phone-vibrate-fill","pie-chart","pie-chart-fill","piggy-bank","piggy-bank-fill","pin","pin-angle","pin-angle-fill","pin-fill","pin-map","pin-map-fill","pinterest","pip","pip-fill","play","play-btn","play-btn-fill","play-circle","play-circle-fill","play-fill","playstation","plug","plug-fill","plugin","plus","plus-circle","plus-circle-dotted","plus-circle-fill","plus-lg","plus-slash-minus","plus-square","plus-square-dotted","plus-square-fill","postage","postage-fill","postage-heart","postage-heart-fill","postcard","postcard-fill","postcard-heart","postcard-heart-fill","power","prescription","prescription2","printer","printer-fill","projector","projector-fill","puzzle","puzzle-fill","qr-code","qr-code-scan","question","question-circle","question-circle-fill","question-diamond","question-diamond-fill","question-lg","question-octagon","question-octagon-fill","question-square","question-square-fill","quora","quote","r-circle","r-circle-fill","r-square","r-square-fill","radar","radioactive","rainbow","receipt","receipt-cutoff","reception-0","reception-1","reception-2","reception-3","reception-4","record","record-btn","record-btn-fill","record-circle","record-circle-fill","record-fill","record2","record2-fill","recycle","reddit","regex","repeat","repeat-1","reply","reply-all","reply-all-fill","reply-fill","rewind","rewind-btn","rewind-btn-fill","rewind-circle","rewind-circle-fill","rewind-fill","robot","rocket","rocket-fill","rocket-takeoff","rocket-takeoff-fill","router","router-fill","rss","rss-fill","rulers","safe","safe-fill","safe2","safe2-fill","save","save-fill","save2","save2-fill","scissors","scooter","screwdriver","sd-card","sd-card-fill","search","search-heart","search-heart-fill","segmented-nav","send","send-arrow-down","send-arrow-down-fill","send-arrow-up","send-arrow-up-fill","send-check","send-check-fill","send-dash","send-dash-fill","send-exclamation","send-exclamation-fill","send-fill","send-plus","send-plus-fill","send-slash","send-slash-fill","send-x","send-x-fill","server","shadows","share","share-fill","shield","shield-check","shield-exclamation","shield-fill","shield-fill-check","shield-fill-exclamation","shield-fill-minus","shield-fill-plus","shield-fill-x","shield-lock","shield-lock-fill","shield-minus","shield-plus","shield-shaded","shield-slash","shield-slash-fill","shield-x","shift","shift-fill","shop","shop-window","shuffle","sign-dead-end","sign-dead-end-fill","sign-do-not-enter","sign-do-not-enter-fill","sign-intersection","sign-intersection-fill","sign-intersection-side","sign-intersection-side-fill","sign-intersection-t","sign-intersection-t-fill","sign-intersection-y","sign-intersection-y-fill","sign-merge-left","sign-merge-left-fill","sign-merge-right","sign-merge-right-fill","sign-no-left-turn","sign-no-left-turn-fill","sign-no-parking","sign-no-parking-fill","sign-no-right-turn","sign-no-right-turn-fill","sign-railroad","sign-railroad-fill","sign-stop","sign-stop-fill","sign-stop-lights","sign-stop-lights-fill","sign-turn-left","sign-turn-left-fill","sign-turn-right","sign-turn-right-fill","sign-turn-slight-left","sign-turn-slight-left-fill","sign-turn-slight-right","sign-turn-slight-right-fill","sign-yield","sign-yield-fill","signal","signpost","signpost-2","signpost-2-fill","signpost-fill","signpost-split","signpost-split-fill","sim","sim-fill","sim-slash","sim-slash-fill","sina-weibo","skip-backward","skip-backward-btn","skip-backward-btn-fill","skip-backward-circle","skip-backward-circle-fill","skip-backward-fill","skip-end","skip-end-btn","skip-end-btn-fill","skip-end-circle","skip-end-circle-fill","skip-end-fill","skip-forward","skip-forward-btn","skip-forward-btn-fill","skip-forward-circle","skip-forward-circle-fill","skip-forward-fill","skip-start","skip-start-btn","skip-start-btn-fill","skip-start-circle","skip-start-circle-fill","skip-start-fill","skype","slack","slash","slash-circle","slash-circle-fill","slash-lg","slash-square","slash-square-fill","sliders","sliders2","sliders2-vertical","smartwatch","snapchat","snow","snow2","snow3","sort-alpha-down","sort-alpha-down-alt","sort-alpha-up","sort-alpha-up-alt","sort-down","sort-down-alt","sort-numeric-down","sort-numeric-down-alt","sort-numeric-up","sort-numeric-up-alt","sort-up","sort-up-alt","soundwave","sourceforge","speaker","speaker-fill","speedometer","speedometer2","spellcheck","spotify","square","square-fill","square-half","stack","stack-overflow","star","star-fill","star-half","stars","steam","stickies","stickies-fill","sticky","sticky-fill","stop","stop-btn","stop-btn-fill","stop-circle","stop-circle-fill","stop-fill","stoplights","stoplights-fill","stopwatch","stopwatch-fill","strava","stripe","subscript","substack","subtract","suit-club","suit-club-fill","suit-diamond","suit-diamond-fill","suit-heart","suit-heart-fill","suit-spade","suit-spade-fill","suitcase","suitcase-fill","suitcase-lg","suitcase-lg-fill","suitcase2","suitcase2-fill","sun","sun-fill","sunglasses","sunrise","sunrise-fill","sunset","sunset-fill","superscript","symmetry-horizontal","symmetry-vertical","table","tablet","tablet-fill","tablet-landscape","tablet-landscape-fill","tag","tag-fill","tags","tags-fill","taxi-front","taxi-front-fill","telegram","telephone","telephone-fill","telephone-forward","telephone-forward-fill","telephone-inbound","telephone-inbound-fill","telephone-minus","telephone-minus-fill","telephone-outbound","telephone-outbound-fill","telephone-plus","telephone-plus-fill","telephone-x","telephone-x-fill","tencent-qq","terminal","terminal-dash","terminal-fill","terminal-plus","terminal-split","terminal-x","text-center","text-indent-left","text-indent-right","text-left","text-paragraph","text-right","text-wrap","textarea","textarea-resize","textarea-t","thermometer","thermometer-half","thermometer-high","thermometer-low","thermometer-snow","thermometer-sun","threads","threads-fill","three-dots","three-dots-vertical","thunderbolt","thunderbolt-fill","ticket","ticket-detailed","ticket-detailed-fill","ticket-fill","ticket-perforated","ticket-perforated-fill","tiktok","toggle-off","toggle-on","toggle2-off","toggle2-on","toggles","toggles2","tools","tornado","train-freight-front","train-freight-front-fill","train-front","train-front-fill","train-lightrail-front","train-lightrail-front-fill","translate","transparency","trash","trash-fill","trash2","trash2-fill","trash3","trash3-fill","tree","tree-fill","trello","triangle","triangle-fill","triangle-half","trophy","trophy-fill","tropical-storm","truck","truck-flatbed","truck-front","truck-front-fill","tsunami","tv","tv-fill","twitch","twitter","twitter-x","type","type-bold","type-h1","type-h2","type-h3","type-h4","type-h5","type-h6","type-italic","type-strikethrough","type-underline","ubuntu","ui-checks","ui-checks-grid","ui-radios","ui-radios-grid","umbrella","umbrella-fill","unindent","union","unity","universal-access","universal-access-circle","unlock","unlock-fill","upc","upc-scan","upload","usb","usb-c","usb-c-fill","usb-drive","usb-drive-fill","usb-fill","usb-micro","usb-micro-fill","usb-mini","usb-mini-fill","usb-plug","usb-plug-fill","usb-symbol","valentine","valentine2","vector-pen","view-list","view-stacked","vignette","vimeo","vinyl","vinyl-fill","virus","virus2","voicemail","volume-down","volume-down-fill","volume-mute","volume-mute-fill","volume-off","volume-off-fill","volume-up","volume-up-fill","vr","wallet","wallet-fill","wallet2","watch","water","webcam","webcam-fill","wechat","whatsapp","wifi","wifi-1","wifi-2","wifi-off","wikipedia","wind","window","window-dash","window-desktop","window-dock","window-fullscreen","window-plus","window-sidebar","window-split","window-stack","window-x","windows","wordpress","wrench","wrench-adjustable","wrench-adjustable-circle","wrench-adjustable-circle-fill","x","x-circle","x-circle-fill","x-diamond","x-diamond-fill","x-lg","x-octagon","x-octagon-fill","x-square","x-square-fill","xbox","yelp","yin-yang","youtube","zoom-in","zoom-out"]
\ No newline at end of file
diff --git a/tests-app/Unit/MenuTreeTest.php b/tests-app/Unit/MenuTreeTest.php
new file mode 100644
index 0000000..623b133
--- /dev/null
+++ b/tests-app/Unit/MenuTreeTest.php
@@ -0,0 +1,96 @@
+ current parent, i.e. rows 1..4 exist. */
+ private function known()
+ {
+ return [1 => 0, 2 => 0, 3 => 0, 4 => 0];
+ }
+
+ public function testFlatOrderStaysFlat(): void
+ {
+ $parents = MenuTree::parents([[1, 0], [2, 0], [3, 0]], [1 => 0, 2 => 0, 3 => 0]);
+
+ $this->assertSame([1 => 0, 2 => 0, 3 => 0], $parents);
+ }
+
+ public function testDepthBecomesAParent(): void
+ {
+ $parents = MenuTree::parents([[1, 0], [2, 1], [3, 1], [4, 0]], $this->known());
+
+ $this->assertSame([1 => 0, 2 => 1, 3 => 1, 4 => 0], $parents);
+ }
+
+ public function testSecondLevelNestsUnderTheItemAboveIt(): void
+ {
+ $parents = MenuTree::parents([[1, 0], [2, 1], [3, 2], [4, 2]], $this->known());
+
+ $this->assertSame([1 => 0, 2 => 1, 3 => 2, 4 => 2], $parents);
+ }
+
+ public function testARowThatSkipsALevelFallsBackToTheNearestAncestor(): void
+ {
+ // Depth 2 straight after a depth 0 item: the missing level is skipped.
+ $parents = MenuTree::parents([[1, 0], [2, 0], [3, 2]], [1 => 0, 2 => 0, 3 => 0]);
+
+ $this->assertSame([1 => 0, 2 => 0, 3 => 2], $parents);
+ }
+
+ public function testUnknownAndDuplicateIdsAreRejected(): void
+ {
+ $this->assertFalse(MenuTree::parents([[1, 0], [99, 0]], $this->known()));
+ $this->assertFalse(MenuTree::parents([[1, 0], [1, 1], [2, 0], [3, 0], [4, 0]], $this->known()));
+ }
+
+ public function testAPartialOrderIsRejected(): void
+ {
+ $this->assertFalse(MenuTree::parents([[1, 0], [2, 0]], $this->known()), 'rows were left out');
+ $this->assertFalse(MenuTree::parents([], $this->known()));
+ }
+
+ public function testDepthsBeyondTheLimitAreRejected(): void
+ {
+ $this->assertFalse(MenuTree::parents([[1, 0], [2, 1], [3, 4]], [1 => 0, 2 => 0, 3 => 0]));
+ $this->assertFalse(MenuTree::parents([[1, -1], [2, 0]], [1 => 0, 2 => 0]));
+ }
+
+ public function testCanNestRules(): void
+ {
+ // 1 (top) > 2 > 3, and 4 alone. ids => parent
+ $tree = [1 => 0, 2 => 1, 3 => 2, 4 => 0];
+
+ $this->assertTrue(MenuTree::canNest($tree, 4, 0), 'top level is always fine');
+ $this->assertTrue(MenuTree::canNest($tree, 4, 1), 'a lone item under a top-level one');
+ $this->assertFalse(MenuTree::canNest($tree, 4, 3), 'under the third level would be level four');
+ $this->assertFalse(MenuTree::canNest($tree, 1, 1), 'never its own parent');
+ $this->assertFalse(MenuTree::canNest($tree, 1, 3), 'never inside its own subtree');
+ $this->assertFalse(MenuTree::canNest($tree, 2, 3), 'a child cannot become its own parent\'s child');
+ $this->assertFalse(MenuTree::canNest($tree, 4, 99), 'the parent must exist');
+ $this->assertFalse(MenuTree::canNest($tree, 1, 4), 'a subtree that is already three levels deep cannot move down');
+ $this->assertTrue(MenuTree::canNest([1 => 0, 2 => 0, 3 => 2], 2, 1), 'a two-level subtree may go under a top-level item');
+ }
+
+ public function testCanNestForAnItemThatDoesNotExistYet(): void
+ {
+ // 1 (top) > 2 > 3 : a new item (id 0) has no subtree of its own
+ $tree = [1 => 0, 2 => 1, 3 => 2, 4 => 0];
+
+ $this->assertTrue(MenuTree::canNest($tree, 0, 0));
+ $this->assertTrue(MenuTree::canNest($tree, 0, 1), 'a new item under a top-level one');
+ $this->assertTrue(MenuTree::canNest($tree, 0, 2), 'a new item may be the third level');
+ $this->assertFalse(MenuTree::canNest($tree, 0, 3), 'but not the fourth');
+ $this->assertFalse(MenuTree::canNest($tree, 0, 99), 'and its parent must exist');
+ }
+
+ public function testCanNestSurvivesLoopsInStoredData(): void
+ {
+ $this->assertFalse(MenuTree::canNest([1 => 2, 2 => 1, 3 => 0], 3, 1), 'a loop above the new parent is unusable');
+ }
+}
+
diff --git a/tests-app/Unit/PageSafetyTest.php b/tests-app/Unit/PageSafetyTest.php
new file mode 100644
index 0000000..77b8f5e
--- /dev/null
+++ b/tests-app/Unit/PageSafetyTest.php
@@ -0,0 +1,112 @@
+assertSame($url, Links::safe($url), $url);
+ }
+ $this->assertSame('https://example.com', Links::safe(' https://example.com '), 'trimmed');
+ }
+
+ public function testRefusedAddresses(): void
+ {
+ $bad = [
+ '', ' ', 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', "java\tscript:alert(1)", "java\nscript:alert(1)", " javascript:alert(1)",
+ 'data:text/html,', 'vbscript:x', 'file:///etc/passwd', 'blob:https://x', 'ftp://example.com',
+ '//evil.example/path', '\\\\evil.example', 'https:///nohost', 'http://', "https://exa mple.com", "https://exa\0mple.com",
+ str_repeat('a', 2001),
+ ];
+ foreach ($bad as $url) {
+ $this->assertNull(Links::safe($url), json_encode($url));
+ }
+ }
+
+ public function testResolveKeepsAbsoluteAndPutsThisSitesPagesUnderTheSiteAddress(): void
+ {
+ $site = static function ($path) {
+ return 'https://app.test/index.php/'.$path;
+ };
+
+ $this->assertSame('https://example.com/x', Links::resolve('https://example.com/x', $site));
+ $this->assertSame('mailto:a@b.c', Links::resolve('mailto:a@b.c', $site));
+ $this->assertSame('https://app.test/index.php/page/about', Links::resolve('page/about', $site));
+ $this->assertSame('https://app.test/index.php/register', Links::resolve('/register', $site));
+ $this->assertSame('#top', Links::resolve('#top', $site));
+ $this->assertNull(Links::resolve('javascript:alert(1)', $site));
+ }
+
+ // ---- Html ----------------------------------------------------------
+
+ public function testNormalMarkupSurvives(): void
+ {
+ $this->assertSame('Hello big world
', Html::sanitize('Hello big world
'));
+ $this->assertSame('', Html::sanitize(''));
+ $this->assertSame('', Html::sanitize(' '));
+ $this->assertSame('Tom & Jerry <3', Html::sanitize('Tom & Jerry <3'));
+ }
+
+ public function testLinksKeepOnlyASafeHrefAndGetRel(): void
+ {
+ $this->assertSame('x ', Html::sanitize('x '));
+ $this->assertSame('x ', Html::sanitize('x '));
+ $this->assertSame('x ', Html::sanitize('x '), 'entity-encoded scheme');
+ $this->assertSame('x ', Html::sanitize("x "), 'tab inside the scheme');
+ $this->assertSame('x ', Html::sanitize('x '));
+ }
+
+ /**
+ * @return array
+ */
+ public static function payloads(): array
+ {
+ return [
+ 'script' => [''],
+ 'script in p' => ['hi
'],
+ 'img onerror' => [' '],
+ 'svg onload' => [''],
+ 'svg script' => [' '],
+ 'math' => [''],
+ 'span handler' => ['hover '],
+ 'p handler' => ['x
'],
+ 'broken tag' => ['ipt>alert(1) ipt>'],
+ 'case' => [''],
+ 'comment' => [''],
+ 'noscript' => [''],
+ 'template' => [' '],
+ 'base' => [' '],
+ 'meta refresh' => [' '],
+ 'textarea' => [''],
+ 'xmp' => ['
'],
+ ];
+ }
+
+ public function testNothingRunnableSurvivesAnyPayload(): void
+ {
+ foreach (self::payloads() as $name => [$payload]) {
+ $out = strtolower(Html::sanitize($payload));
+ foreach (['']],
+ 'not an array',
+ ]);
+
+ $this->assertCount(1, $sections);
+ $this->assertSame('hero', $sections[0]['type']);
+ $this->assertSame(['heading' => 'Hello'], $sections[0]['settings'], 'unknown settings are dropped');
+ }
+
+ public function testNormalizeAcceptsJsonAndReturnsEmptyForNothing(): void
+ {
+ $this->assertSame([], SectionTypes::normalize(''));
+ $this->assertSame([], SectionTypes::normalize(null));
+ $this->assertSame([], SectionTypes::toArray('not json'));
+
+ $fromJson = SectionTypes::toArray('[{"type":"text","settings":{"body":"Hi"}}]');
+ $this->assertSame('text', $fromJson[0]['type']);
+ $this->assertSame('Hi', $fromJson[0]['settings']['body']);
+ }
+
+ public function testEncodeKeepsARoundTrip(): void
+ {
+ $sections = [['type' => 'cta', 'settings' => ['heading' => 'Join', 'button_label' => 'Sign up']]];
+ $json = SectionTypes::encode($sections);
+
+ $this->assertSame($sections, json_decode($json, true));
+ }
+
+ public function testAVeryLongValueIsCutSoOnePageStillFitsTheColumn(): void
+ {
+ $sections = SectionTypes::normalize([
+ ['type' => 'text', 'settings' => ['body' => str_repeat('a', SectionTypes::MAX_VALUE + 500)]],
+ ]);
+
+ $this->assertSame(SectionTypes::MAX_VALUE, mb_strlen($sections[0]['settings']['body']));
+ }
+
+ public function testRenderedValuesAreEscaped(): void
+ {
+ $html = SectionTypes::render([
+ 'type' => 'hero',
+ 'settings' => ['heading' => '', 'button_label' => 'Go', 'button_url' => 'javascript:alert(1)'],
+ ]);
+
+ $this->assertStringNotContainsString('