From 07bc784859c36bcc176b27c04b2996cdca5bb73f Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 12:58:30 +0100
Subject: [PATCH 01/79] Add bootstrap as a dependency

---
 composer.json | 5 ++++-
 public/vendor | 1 +
 2 files changed, 5 insertions(+), 1 deletion(-)
 create mode 120000 public/vendor

diff --git a/composer.json b/composer.json
index 2ad1e40..a4b52e4 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,9 @@
         "ext-mysqli": "*",
         "ext-imagick": "*",
         "ext-gd": "*",
-        "ext-fileinfo": "*"
+        "ext-imagick": "*",
+        "ext-mysqli": "*",
+        "twbs/bootstrap": "^5.3",
+        "twbs/bootstrap-icons": "^1.10"
     }
 }
diff --git a/public/vendor b/public/vendor
new file mode 120000
index 0000000..42a408b
--- /dev/null
+++ b/public/vendor
@@ -0,0 +1 @@
+../vendor/
\ No newline at end of file
-- 
2.46.0


From daf6b6b26408a6166831fde7ecd4b4e72df8528b Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:12:12 +0100
Subject: [PATCH 02/79] MainTemplate: clean up HTML head; remove unused inline
 CSS function

---
 templates/MainTemplate.php | 27 +++++++++++++++------------
 1 file changed, 15 insertions(+), 12 deletions(-)

diff --git a/templates/MainTemplate.php b/templates/MainTemplate.php
index 87a1c0d..b5224fb 100644
--- a/templates/MainTemplate.php
+++ b/templates/MainTemplate.php
@@ -25,14 +25,22 @@ class MainTemplate extends Template
 		echo '<!DOCTYPE html>
 <html lang="en">
 	<head>
-		<title>', $this->title, '</title>', !empty($this->canonical_url) ? '
-		<link rel="canonical" href="' . $this->canonical_url . '">' : '', '
+		<title>', $this->title, '</title>';
+
+		if (!empty($this->canonical_url))
+			echo '
+		<link rel="canonical" href="', $this->canonical_url, '">';
+
+		echo '
+		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+		<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
+
+		echo '
+		<link rel="stylesheet" href="', BASEURL, '/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
+		<link rel="stylesheet" href="', BASEURL, '/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
 		<link type="text/css" rel="stylesheet" href="', BASEURL, '/css/default.css">
-		<meta name="viewport" content="width=device-width, initial-scale=1">
-		<meta http-equiv="Content-Type" content="text/html; charset=utf-8">', !empty($this->css) ? '
-		<style type="text/css">' . $this->css . '
-		</style>' : '', $this->header_html, '
-		<script type="text/javascript" src="', BASEURL, '/js/main.js"></script>
+		<script type="text/javascript" src="', BASEURL, '/js/main.js"></script>'
+		, $this->header_html, '
 	</head>
 	<body', !empty($this->classes) ? ' class="' . implode(' ', $this->classes) . '"' : '', '>
 		<header>
@@ -84,11 +92,6 @@ class MainTemplate extends Template
 </html>';
 	}
 
-	public function appendCss($css)
-	{
-		$this->css .= $css;
-	}
-
 	public function appendHeaderHtml($html)
 	{
 		$this->header_html .= "\n\t\t" . $html;
-- 
2.46.0


From f9eefe7b4192452834193064295eea7c5b13e5b3 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:20:59 +0100
Subject: [PATCH 03/79] Replace generic alert, form and table templates with
 new Bootstrap equivalents

---
 controllers/ManageAlbums.php   |   4 +-
 controllers/ManageErrors.php   |   4 +-
 controllers/ManageTags.php     |   4 +-
 controllers/ManageUsers.php    |   4 +-
 controllers/ViewPeople.php     |   2 +-
 controllers/ViewPhotoAlbum.php |   2 +-
 controllers/ViewTimeline.php   |   2 +-
 models/Form.php                | 341 +++++++++++++++++++++++++--------
 models/GenericTable.php        |  14 +-
 public/css/admin.css           | 139 --------------
 public/css/default.css         | 156 +--------------
 templates/AlbumIndex.php       |   2 +-
 templates/Alert.php            |  20 +-
 templates/FormView.php         | 261 ++++++++++++++++++-------
 templates/PageIndexWidget.php  |  61 ++++++
 templates/Pagination.php       |  64 -------
 templates/PhotosIndex.php      |   2 +-
 templates/TabularData.php      | 172 +++++++++++++----
 18 files changed, 681 insertions(+), 573 deletions(-)
 create mode 100644 templates/PageIndexWidget.php
 delete mode 100644 templates/Pagination.php

diff --git a/controllers/ManageAlbums.php b/controllers/ManageAlbums.php
index f6a1b59..f989e6a 100644
--- a/controllers/ManageAlbums.php
+++ b/controllers/ManageAlbums.php
@@ -18,7 +18,7 @@ class ManageAlbums extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/editalbum/',
 				'method' => 'get',
-				'class' => 'floatright',
+				'class' => 'float-end',
 				'buttons' => [
 					'add' => [
 						'type' => 'submit',
@@ -60,7 +60,7 @@ class ManageAlbums extends HTMLController
 			'title' => 'Manage albums',
 			'no_items_label' => 'No albums meet the requirements of the current filter.',
 			'items_per_page' => 9999,
-			'index_class' => 'floatleft',
+			'index_class' => 'float-start',
 			'base_url' => BASEURL . '/managealbums/',
 			'get_data' => function($offset = 0, $limit = 9999, $order = '', $direction = 'up') {
 				if (!in_array($order, ['id_tag', 'tag', 'slug', 'count']))
diff --git a/controllers/ManageErrors.php b/controllers/ManageErrors.php
index d45d37f..9619753 100644
--- a/controllers/ManageErrors.php
+++ b/controllers/ManageErrors.php
@@ -29,7 +29,7 @@ class ManageErrors extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/manageerrors/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
 				'method' => 'post',
-				'class' => 'floatright',
+				'class' => 'float-end',
 				'buttons' => [
 					'flush' => [
 						'type' => 'submit',
@@ -99,7 +99,7 @@ class ManageErrors extends HTMLController
 			'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '',
 			'no_items_label' => "No errors to display -- we're all good!",
 			'items_per_page' => 20,
-			'index_class' => 'floatleft',
+			'index_class' => 'float-start',
 			'base_url' => BASEURL . '/manageerrors/',
 			'get_count' => 'ErrorLog::getCount',
 			'get_data' => function($offset = 0, $limit = 20, $order = '', $direction = 'down') {
diff --git a/controllers/ManageTags.php b/controllers/ManageTags.php
index b600f2d..a49f9aa 100644
--- a/controllers/ManageTags.php
+++ b/controllers/ManageTags.php
@@ -18,7 +18,7 @@ class ManageTags extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/edittag/',
 				'method' => 'get',
-				'class' => 'floatright',
+				'class' => 'float-end',
 				'buttons' => [
 					'add' => [
 						'type' => 'submit',
@@ -65,7 +65,7 @@ class ManageTags extends HTMLController
 			'title' => 'Manage tags',
 			'no_items_label' => 'No tags meet the requirements of the current filter.',
 			'items_per_page' => 30,
-			'index_class' => 'floatleft',
+			'index_class' => 'float-start',
 			'base_url' => BASEURL . '/managetags/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'up') {
 				if (!in_array($order, ['id_tag', 'tag', 'slug', 'kind', 'count']))
diff --git a/controllers/ManageUsers.php b/controllers/ManageUsers.php
index d7fadbd..14ef97f 100644
--- a/controllers/ManageUsers.php
+++ b/controllers/ManageUsers.php
@@ -18,7 +18,7 @@ class ManageUsers extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/edituser/',
 				'method' => 'get',
-				'class' => 'floatright',
+				'class' => 'float-end',
 				'buttons' => [
 					'add' => [
 						'type' => 'submit',
@@ -94,7 +94,7 @@ class ManageUsers extends HTMLController
 			'title' => 'Manage users',
 			'no_items_label' => 'No users meet the requirements of the current filter.',
 			'items_per_page' => 30,
-			'index_class' => 'floatleft',
+			'index_class' => 'float-start',
 			'base_url' => BASEURL . '/manageusers/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
 				if (!in_array($order, ['id_user', 'surname', 'first_name', 'slug', 'emailaddress', 'last_action_time', 'ip_address', 'is_admin']))
diff --git a/controllers/ViewPeople.php b/controllers/ViewPeople.php
index bf7d8ba..3829327 100644
--- a/controllers/ViewPeople.php
+++ b/controllers/ViewPeople.php
@@ -53,7 +53,7 @@ class ViewPeople extends HTMLController
 			'base_url' => BASEURL . '/people/',
 			'page_slug' => 'page/%PAGE%/',
 		]);
-		$this->page->adopt(new Pagination($pagination));
+		$this->page->adopt(new PageIndexWidget($pagination));
 
 		$this->page->setCanonicalUrl(BASEURL . '/people/' . ($page > 1 ? 'page/' . $page . '/' : ''));
 	}
diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php
index aa0f10a..73c84e2 100644
--- a/controllers/ViewPhotoAlbum.php
+++ b/controllers/ViewPhotoAlbum.php
@@ -112,7 +112,7 @@ class ViewPhotoAlbum extends HTMLController
 				'base_url' => BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : ''),
 				'page_slug' => 'page/%PAGE%/',
 			]);
-			$this->page->adopt(new Pagination($index));
+			$this->page->adopt(new PageIndexWidget($index));
 		}
 
 		// Set the canonical url.
diff --git a/controllers/ViewTimeline.php b/controllers/ViewTimeline.php
index 3aa29b9..bf9d56e 100644
--- a/controllers/ViewTimeline.php
+++ b/controllers/ViewTimeline.php
@@ -47,7 +47,7 @@ class ViewTimeline extends HTMLController
 				'base_url' => BASEURL . '/timeline/',
 				'page_slug' => 'page/%PAGE%/',
 			]);
-			$this->page->adopt(new Pagination($index));
+			$this->page->adopt(new PageIndexWidget($index));
 		}
 
 		// Set the canonical url.
diff --git a/models/Form.php b/models/Form.php
index 62952ae..3558533 100644
--- a/models/Form.php
+++ b/models/Form.php
@@ -3,7 +3,8 @@
  * Form.php
  * Contains key class Form.
  *
- * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
  *****************************************************************************/
 
 class Form
@@ -12,9 +13,11 @@ class Form
 	public $request_url;
 	public $content_above;
 	public $content_below;
-	private $fields;
-	private $data;
-	private $missing;
+	private $fields = [];
+	private $data = [];
+	private $missing = [];
+	private $submit_caption;
+	private $trim_inputs;
 
 	// NOTE: this class does not verify the completeness of form options.
 	public function __construct($options)
@@ -24,9 +27,42 @@ class Form
 		$this->fields = !empty($options['fields']) ? $options['fields'] : [];
 		$this->content_below = !empty($options['content_below']) ? $options['content_below'] : null;
 		$this->content_above = !empty($options['content_above']) ? $options['content_above'] : null;
+		$this->submit_caption = !empty($options['submit_caption']) ? $options['submit_caption'] : 'Save information';
+		$this->trim_inputs = !empty($options['trim_inputs']);
 	}
 
-	public function verify($post)
+	public function getFields()
+	{
+		return $this->fields;
+	}
+
+	public function getData()
+	{
+		return $this->data;
+	}
+
+	public function getSubmitButtonCaption()
+	{
+		return $this->submit_caption;
+	}
+
+	public function getMissing()
+	{
+		return $this->missing;
+	}
+
+	public function setData($data)
+	{
+		$this->verify($data, true);
+		$this->missing = [];
+	}
+
+	public function setFieldAsMissing($field)
+	{
+		$this->missing[] = $field;
+	}
+
+	public function verify($post, $initalisation = false)
 	{
 		$this->data = [];
 		$this->missing = [];
@@ -41,30 +77,43 @@ class Form
 			}
 
 			// No data present at all for this field?
-			if ((!isset($post[$field_id]) || $post[$field_id] == '') && empty($field['is_optional']))
+			if ((!isset($post[$field_id]) || $post[$field_id] == '') &&
+				$field['type'] !== 'captcha')
 			{
-				$this->missing[] = $field_id;
-				$this->data[$field_id] = '';
+				if (empty($field['is_optional']))
+					$this->missing[] = $field_id;
+
+				if ($field['type'] === 'select' && !empty($field['multiple']))
+					$this->data[$field_id] = [];
+				else
+					$this->data[$field_id] = '';
+
 				continue;
 			}
 
-			// Verify data for all fields
+			// Should we trim this?
+			if ($this->trim_inputs && $field['type'] !== 'captcha' && empty($field['multiple']))
+				$post[$field_id] = trim($post[$field_id]);
+
+			// Using a custom validation function?
+			if (isset($field['validate']) && is_callable($field['validate']))
+			{
+				// Validation functions can clean up the data if passed by reference
+				$this->data[$field_id] = $post[$field_id];
+
+				// Evaluate validation functions as boolean to see if data is missing
+				if (!$field['validate']($post[$field_id]))
+					$this->missing[] = $field_id;
+
+				continue;
+			}
+
+			// Verify data by field type
 			switch ($field['type'])
 			{
 				case 'select':
 				case 'radio':
-					// Skip validation? Dangerous territory!
-					if (isset($field['verify_options']) && $field['verify_options'] === false)
-						$this->data[$field_id] = $post[$field_id];
-					// Check whether selected option is valid.
-					elseif (isset($post[$field_id]) && !isset($field['options'][$post[$field_id]]))
-					{
-						$this->missing[] = $field_id;
-						$this->data[$field_id] = '';
-						continue 2;
-					}
-					else
-						$this->data[$field_id] = $post[$field_id];
+					$this->validateSelect($field_id, $field, $post);
 					break;
 
 				case 'checkbox':
@@ -73,61 +122,22 @@ class Form
 					break;
 
 				case 'color':
-					// Colors are stored as a string of length 3 or 6 (hex)
-					if (!isset($post[$field_id]) || (strlen($post[$field_id]) != 3 && strlen($post[$field_id]) != 6))
-					{
-						$this->missing[] = $field_id;
-						$this->data[$field_id] = '';
-						continue 2;
-					}
-					else
-						$this->data[$field_id] = $post[$field_id];
+					$this->validateColor($field_id, $field, $post);
 					break;
 
 				case 'file':
-					// Needs to be verified elsewhere!
+					// Asset needs to be processed out of POST! This is just a filename.
+					$this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
 					break;
 
 				case 'numeric':
-					$data = isset($post[$field_id]) ? $post[$field_id] : '';
-					// Do we need to check bounds?
-					if (isset($field['min_value']) && is_numeric($data))
-					{
-						if (is_float($field['min_value']) && (float) $data < $field['min_value'])
-						{
-							$this->missing[] = $field_id;
-							$this->data[$field_id] = 0.0;
-						}
-						elseif (is_int($field['min_value']) && (int) $data < $field['min_value'])
-						{
-							$this->missing[] = $field_id;
-							$this->data[$field_id] = 0;
-						}
-						else
-							$this->data[$field_id] = $data;
-					}
-					elseif (isset($field['max_value']) && is_numeric($data))
-					{
-						if (is_float($field['max_value']) && (float) $data > $field['max_value'])
-						{
-							$this->missing[] = $field_id;
-							$this->data[$field_id] = 0.0;
-						}
-						elseif (is_int($field['max_value']) && (int) $data > $field['max_value'])
-						{
-							$this->missing[] = $field_id;
-							$this->data[$field_id] = 0;
-						}
-						else
-							$this->data[$field_id] = $data;
-					}
-					// Does it look numeric?
-					elseif (is_numeric($data))
-					{
-						$this->data[$field_id] = $data;
-					}
-					// Let's consider it missing, then.
-					else
+					$this->validateNumeric($field_id, $field, $post);
+					break;
+
+				case 'captcha':
+					if (isset($_POST['g-recaptcha-response']) && !$initalisation)
+						$this->validateCaptcha($field_id);
+					elseif (!$initalisation)
 					{
 						$this->missing[] = $field_id;
 						$this->data[$field_id] = 0;
@@ -137,29 +147,200 @@ class Form
 				case 'text':
 				case 'textarea':
 				default:
-					$this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
+					$this->validateText($field_id, $field, $post);
 			}
 		}
 	}
 
-	public function setData($data)
+	private function validateCaptcha($field_id)
 	{
-		$this->verify($data);
-		$this->missing = [];
+		$postdata = http_build_query([
+			'secret' => RECAPTCHA_API_SECRET,
+			'response' => $_POST['g-recaptcha-response'],
+		]);
+
+		$opts = [
+			'http' => [
+				'method'  => 'POST',
+				'header'  => 'Content-type: application/x-www-form-urlencoded',
+				'content' => $postdata,
+			]
+		];
+
+		$context  = stream_context_create($opts);
+		$result = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context);
+		$check = json_decode($result);
+
+		if ($check->success)
+		{
+			$this->data[$field_id] = 1;
+		}
+		else
+		{
+			$this->data[$field_id] = 0;
+			$this->missing[] = $field_id;
+		}
 	}
 
-	public function getFields()
+	private function validateColor($field_id, array $field, array $post)
 	{
-		return $this->fields;
+		// Colors are stored as a string of length 3 or 6 (hex)
+		if (!isset($post[$field_id]) || (strlen($post[$field_id]) != 3 && strlen($post[$field_id]) != 6))
+		{
+			$this->missing[] = $field_id;
+			$this->data[$field_id] = '';
+		}
+		else
+			$this->data[$field_id] = $post[$field_id];
 	}
 
-	public function getData()
+	private function validateNumeric($field_id, array $field, array $post)
 	{
-		return $this->data;
+		$data = isset($post[$field_id]) ? $post[$field_id] : '';
+
+		// Sanity check: does this even look numeric?
+		if (!is_numeric($data))
+		{
+			$this->missing[] = $field_id;
+			$this->data[$field_id] = 0;
+			return;
+		}
+
+		// Do we need to a minimum bound?
+		if (isset($field['min_value']))
+		{
+			if (is_float($field['min_value']) && (float) $data < $field['min_value'])
+			{
+				$this->missing[] = $field_id;
+				$this->data[$field_id] = 0.0;
+			}
+			elseif (is_int($field['min_value']) && (int) $data < $field['min_value'])
+			{
+				$this->missing[] = $field_id;
+				$this->data[$field_id] = 0;
+			}
+		}
+
+		// What about a maximum bound?
+		if (isset($field['max_value']))
+		{
+			if (is_float($field['max_value']) && (float) $data > $field['max_value'])
+			{
+				$this->missing[] = $field_id;
+				$this->data[$field_id] = 0.0;
+			}
+			elseif (is_int($field['max_value']) && (int) $data > $field['max_value'])
+			{
+				$this->missing[] = $field_id;
+				$this->data[$field_id] = 0;
+			}
+		}
+
+		$this->data[$field_id] = $data;
 	}
 
-	public function getMissing()
+	private function validateSelect($field_id, array $field, array $post)
 	{
-		return $this->missing;
+		// Skip validation? Dangerous territory!
+		if (isset($field['verify_options']) && $field['verify_options'] === false)
+		{
+			$this->data[$field_id] = $post[$field_id];
+			return;
+		}
+
+		// Check whether selected option is valid.
+		if (($field['type'] !== 'select' || empty($field['multiple'])) && empty($field['has_groups']))
+		{
+			if (isset($post[$field_id]) && !isset($field['options'][$post[$field_id]]))
+			{
+				$this->missing[] = $field_id;
+				$this->data[$field_id] = '';
+				return;
+			}
+			else
+				$this->data[$field_id] = $post[$field_id];
+		}
+		// Multiple selections involve a bit more work.
+		elseif (!empty($field['multiple']) && empty($field['has_groups']))
+		{
+			$this->data[$field_id] = [];
+			if (!is_array($post[$field_id]))
+			{
+				if (isset($field['options'][$post[$field_id]]))
+					$this->data[$field_id][] = $post[$field_id];
+				else
+					$this->missing[] = $field_id;
+				return;
+			}
+
+			foreach ($post[$field_id] as $option)
+			{
+				if (isset($field['options'][$option]))
+					$this->data[$field_id][] = $option;
+			}
+
+			if (empty($this->data[$field_id]))
+				$this->missing[] = $field_id;
+		}
+		// Any optgroups involved?
+		elseif (!empty($field['has_groups']))
+		{
+			if (!isset($post[$field_id]))
+			{
+				$this->missing[] = $field_id;
+				$this->data[$field_id] = '';
+				return;
+			}
+
+			// Expensive: iterate over all groups until the value selected has been found.
+			foreach ($field['options'] as $label => $options)
+			{
+				if (is_array($options))
+				{
+					// Consider each of the options as a valid a value.
+					foreach ($options as $value => $label)
+					{
+						if ($post[$field_id] === $value)
+						{
+							$this->data[$field_id] = $options;
+							return;
+						}
+					}
+				}
+				else
+				{
+					// This is an ungrouped value in disguise! Treat it as such.
+					if ($post[$field_id] === $options)
+					{
+						$this->data[$field_id] = $options;
+						return;
+					}
+					else
+						continue;
+				}
+			}
+
+			// If we've reached this point, we'll consider the data invalid.
+			$this->missing[] = $field_id;
+			$this->data[$field_id] = '';
+		}
+		else
+		{
+			throw new UnexpectedValueException('Unexpected field configuration in validateSelect!');
+		}
+	}
+
+	private function validateText($field_id, array $field, array $post)
+	{
+		$this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
+
+		// Trim leading and trailing whitespace?
+		if (!empty($field['trim']))
+			$this->data[$field_id] = trim($this->data[$field_id]);
+
+		// Is there a length limit to enforce?
+		if (isset($field['maxlength']) && strlen($post[$field_id]) > $field['maxlength']) {
+			$post[$field_id] = substr($post[$field_id], 0, $field['maxlength']);
+		}
 	}
 }
diff --git a/models/GenericTable.php b/models/GenericTable.php
index f2147f7..d98220a 100644
--- a/models/GenericTable.php
+++ b/models/GenericTable.php
@@ -3,7 +3,8 @@
  * GenericTable.php
  * Contains key class GenericTable.
  *
- * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2021
  *****************************************************************************/
 
 class GenericTable
@@ -19,7 +20,7 @@ class GenericTable
 
 	public $form_above;
 	public $form_below;
-
+	private $table_class;
 	private $sort_direction;
 	private $sort_order;
 	private $base_url;
@@ -84,6 +85,8 @@ class GenericTable
 		else
 			$this->body = $options['no_items_label'] ?? '';
 
+		$this->table_class = $options['table_class'] ?? '';
+
 		// Got a title?
 		$this->title = $options['title'] ?? '';
 		$this->title_class = $options['title_class'] ?? '';
@@ -105,6 +108,7 @@ class GenericTable
 
 			$header = [
 				'class' => isset($column['class']) ? $column['class'] : '',
+				'cell_class' => isset($column['cell_class']) ? $column['cell_class'] : null,
 				'colspan' => !empty($column['header_colspan']) ? $column['header_colspan'] : 1,
 				'href' => $isSortable ? $this->getLink($this->start, $key, $sortDirection) : null,
 				'label' => $column['header'],
@@ -168,6 +172,11 @@ class GenericTable
 		return $this->pageIndex;
 	}
 
+	public function getTableClass()
+	{
+		return $this->table_class;
+	}
+
 	public function getTitle()
 	{
 		return $this->title;
@@ -196,6 +205,7 @@ class GenericTable
 
 				// Append the cell to the row.
 				$newRow['cells'][] = [
+					'class' => $column['cell_class'] ?? '',
 					'value' => $value,
 				];
 			}
diff --git a/public/css/admin.css b/public/css/admin.css
index f3f3b90..93007cc 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -11,13 +11,6 @@
 	margin: 0 0 0.2em;
 }
 
-.floatleft {
-	float: left;
-}
-.floatright {
-	float: right;
-}
-
 /* Admin bar styles
 ---------------------*/
 body {
@@ -81,22 +74,6 @@ body {
 }
 
 
-/* Edit user screen
----------------------*/
-.edituser dt {
-	clear: left;
-	float: left;
-	width: 150px;
-}
-.edituser dd {
-	float: left;
-	margin-bottom: 5px;
-}
-.edituser form div:last-child {
-	padding: 1em 0 0;
-}
-
-
 /* Admin widgets
 ------------------*/
 .widget {
@@ -195,119 +172,3 @@ body {
 	top: 400px;
 	left: 300px;
 }
-
-
-/* The pagination styles below are based on Bootstrap 2.3.2
--------------------------------------------------------------*/
-
-.table_pagination, .table_form {
-	margin: 20px 0;
-}
-
-.table_pagination ul {
-	display: inline-block;
-	margin: 0;
-	padding: 0;
-	box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
-}
-
-.table_pagination ul > li {
-	display: inline;
-}
-
-.table_pagination ul > li > a,
-.table_pagination ul > li > span {
-	float: left;
-	padding: 4px 12px;
-	line-height: 20px;
-	text-decoration: none;
-	background-color: #ffffff;
-	border: 1px solid #dddddd;
-	border-left-width: 0;
-}
-
-.table_pagination ul > li > a:hover,
-.table_pagination ul > li > a:focus,
-.table_pagination ul > .active > a,
-.table_pagination ul > .active > span {
-	background-color: #f5f5f5;
-}
-
-.table_pagination ul > .active > a,
-.table_pagination ul > .active > span {
-	color: #999999;
-	cursor: default;
-}
-
-.table_pagination ul > .disabled > span,
-.table_pagination ul > .disabled > a,
-.table_pagination ul > .disabled > a:hover,
-.table_pagination ul > .disabled > a:focus {
-	color: #999999;
-	cursor: default;
-	background-color: transparent;
-}
-
-.table_pagination ul > li:first-child > a,
-.table_pagination ul > li:first-child > span {
-	border-left-width: 1px;
-}
-
-
-/* The table styles below were taken from Bootstrap 2.3.2
------------------------------------------------------------*/
-table {
-	max-width: 100%;
-	background-color: transparent;
-	border-collapse: collapse;
-	border-spacing: 0;
-}
-
-.table {
-	width: 100%;
-	margin-bottom: 20px;
-}
-
-.table th,
-.table td {
-	border-top: 1px solid #dddddd;
-	line-height: 20px;
-	padding: 8px;
-	text-align: left;
-	vertical-align: top;
-}
-
-.table th {
-	font-weight: bold;
-}
-
-.table thead th {
-	vertical-align: bottom;
-}
-
-.table caption + thead tr:first-child th,
-.table caption + thead tr:first-child td,
-.table colgroup + thead tr:first-child th,
-.table colgroup + thead tr:first-child td,
-.table thead:first-child tr:first-child th,
-.table thead:first-child tr:first-child td {
-	border-top: 0;
-}
-
-.table tbody + tbody {
-	border-top: 2px solid #dddddd;
-}
-
-.table .table {
-	background-color: #ffffff;
-}
-
-.table-striped tbody > tr:nth-child(odd) > td,
-.table-striped tbody > tr:nth-child(odd) > th {
-	background-color: #f9f9f9;
-}
-
-.table-hover tbody tr:hover > td,
-.table-hover tbody tr:hover > th {
-	background-color: #f5f5f5;
-}
diff --git a/public/css/default.css b/public/css/default.css
index 0aadb89..f4ecab9 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -15,7 +15,7 @@
 }
 
 body {
-	font: 13px/1.7 "Open Sans", sans-serif;
+	font-family: "Open Sans", sans-serif;
 	padding: 0 0 3em;
 	margin: 0;
 	background: #aaa 0 -50% fixed;
@@ -94,51 +94,6 @@ ul#nav li a:hover {
 }
 
 
-/* Pagination
----------------*/
-.pagination {
-	clear: both;
-	text-align: center;
-}
-.pagination ul {
-	display: inline-block;
-	margin: 0;
-	padding: 0;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-}
-.pagination ul > li {
-	display: inline;
-}
-.pagination ul > li > a, .pagination ul > li > span {
-	float: left;
-	font: 300 18px/2.2 "Open Sans", sans-serif;
-	padding: 6px 22px;
-	text-decoration: none;
-	background-color: #fff;
-	border-right: 1px solid #ddd;
-}
-
-.pagination ul > li > a:hover, .pagination ul > li > a:focus,
-.pagination ul > .active > a, .pagination ul > .active > span {
-	background-color: #eee;
-}
-
-.pagination ul > .active > a, .pagination ul > .active > span {
-	cursor: default;
-}
-
-.pagination ul > .disabled > span, .pagination ul > .disabled > a,
-.pagination ul > .disabled > a:hover, .pagination ul > .disabled > a:focus {
-	color: #999;
-	cursor: default;
-	background-color: transparent;
-}
-
-.pagination .page-padding {
-		cursor: pointer;
-}
-
-
 /* Tiled grid
 ---------------*/
 .tiled_header {
@@ -372,51 +327,6 @@ footer a {
 
 /* Input
 ----------*/
-
-input, select, .btn {
-	background: #fff;
-	border: 1px solid #dbdbdb;
-	border-radius: 4px;
-	color: #000;
-	font: 13px/1.7 "Open Sans", "Helvetica", sans-serif;
-	padding: 3px;
-}
-textarea {
-	border: 1px solid #dbdbdb;
-	border-radius: 4px;
-	font: 14px/1.4 'Inconsolata', 'DejaVu Sans Mono', monospace;
-	padding: 0.75%;
-}
-
-input[type=submit], button, .btn {
-	background-color: #eee;
-	border-color: #dbdbdb;
-	border-width: 1px;
-	border-radius: 4px;
-	color: #363636;
-	cursor: pointer;
-	display: inline-block;
-	justify-content: center;
-	padding-bottom: calc(0.4em - 1px);
-	padding-left: 0.8em;
-	padding-right: 0.8em;
-	padding-top: calc(0.4em - 1px);
-	text-align: center;
-	white-space: nowrap;
-}
-input:hover, select:hover, button:hover, .btn:hover {
-	border-color: #b5b5b5;
-}
-input:focus, select:focus, button:focus, .btn:focus {
-	border-color: #3273dc;
-}
-input:focus:not(:active), select:focus:not(:active), button:focus:not(:active), .btn:focus:not(:active) {
-	box-shadow: 0px 0px 0px 2px rgba(50, 115, 220, 0.25);
-}
-input:active, select:active, button:active, .btn:active {
-	border-color: #4a4a4a;
-}
-
 .btn-red {
 	background: #eebbaa;
 	border-color: #cc9988;
@@ -474,70 +384,6 @@ input:active, select:active, button:active, .btn:active {
 }
 
 
-/* Alert boxes -- styling borrowed from Bootstrap 2
------------------------------------------------------*/
-.alert {
-	padding: 8px 35px 8px 14px;
-	margin-bottom: 20px;
-	text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
-	background-color: #fcf8e3;
-	border: 1px solid #fbeed5;
-	-webkit-border-radius: 4px;
-	-moz-border-radius: 4px;
-	border-radius: 4px;
-}
-.alert,
-.alert h4 {
-	color: #c09853;
-}
-.alert h4 {
-	margin: 0;
-}
-.alert .close {
-	position: relative;
-	top: -2px;
-	right: -21px;
-	line-height: 20px;
-}
-.alert-success {
-	background-color: #dff0d8;
-	border-color: #d6e9c6;
-	color: #468847;
-}
-.alert-success h4 {
-	color: #468847;
-}
-.alert-danger,
-.alert-error {
-	background-color: #f2dede;
-	border-color: #eed3d7;
-	color: #b94a48;
-}
-.alert-danger h4,
-.alert-error h4 {
-	color: #b94a48;
-}
-.alert-info {
-	background-color: #d9edf7;
-	border-color: #bce8f1;
-	color: #3a87ad;
-}
-.alert-info h4 {
-	color: #3a87ad;
-}
-.alert-block {
-	padding-top: 14px;
-	padding-bottom: 14px;
-}
-.alert-block > p,
-.alert-block > ul {
-	margin-bottom: 0;
-}
-.alert-block p + p {
-	margin-top: 5px;
-}
-
-
 /* Styling for the photo pages
 --------------------------------*/
 #photo_frame {
diff --git a/templates/AlbumIndex.php b/templates/AlbumIndex.php
index 29a856b..7fdcf64 100644
--- a/templates/AlbumIndex.php
+++ b/templates/AlbumIndex.php
@@ -26,7 +26,7 @@ class AlbumIndex extends SubTemplate
 	protected function html_content()
 	{
 		echo '
-			<div class="tiled_grid">';
+			<div class="tiled_grid clearfix">';
 
 		foreach (array_chunk($this->albums, 3) as $photos)
 		{
diff --git a/templates/Alert.php b/templates/Alert.php
index d5a2091..4f24031 100644
--- a/templates/Alert.php
+++ b/templates/Alert.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2015, Aaron van Geffen
  *****************************************************************************/
 
-class Alert extends SubTemplate
+class Alert extends Template
 {
 	private $_type;
 	private $_message;
@@ -16,20 +16,20 @@ class Alert extends SubTemplate
 	{
 		$this->_title = $title;
 		$this->_message = $message;
-		$this->_type = in_array($type, ['alert', 'error', 'success', 'info']) ? $type : 'alert';
+		$this->_type = in_array($type, ['success', 'info', 'warning', 'danger']) ? $type : 'info';
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
-					<div class="alert', $this->_type != 'alert' ? ' alert-' . $this->_type : '', '">', (!empty($this->_title) ? '
-						<strong>' . $this->_title . '</strong><br>' : ''), '<p>', $this->_message, '</p>';
-
-		$this->additional_alert_content();
-
-		echo '</div>';
+					<div class="alert', $this->_type !== 'alert' ? ' alert-' . $this->_type : '', '">'
+						, !empty($this->_title) ? '<strong>' . $this->_title . '</strong><br>' : '', '
+						', $this->_message,
+						$this->additional_alert_content(), '
+					</div>';
 	}
 
 	protected function additional_alert_content()
-	{}
+	{
+	}
 }
diff --git a/templates/FormView.php b/templates/FormView.php
index 389fa11..5b775ef 100644
--- a/templates/FormView.php
+++ b/templates/FormView.php
@@ -3,52 +3,42 @@
  * FormView.php
  * Contains the form template.
  *
- * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
  *****************************************************************************/
 
 class FormView extends SubTemplate
 {
-	private $content_below;
-	private $content_above;
-	private $data;
-	private $missing;
-	private $fields;
-	private $request_method;
-	private $request_url;
+	private $form;
+	private array $data;
+	private array $missing;
 	private $title;
 
 	public function __construct(Form $form, $title = '')
 	{
+		$this->form = $form;
 		$this->title = $title;
-		$this->request_url = $form->request_url;
-		$this->request_method = $form->request_method;
-		$this->fields = $form->getFields();
-		$this->missing = $form->getMissing();
-		$this->data = $form->getData();
-		$this->content_above = $form->content_above;
-		$this->content_below = $form->content_below;
 	}
 
 	protected function html_content($exclude = [], $include = [])
 	{
 		if (!empty($this->title))
 			echo '
-			<div class="admin_box">
-				<h2>', htmlspecialchars($this->title), '</h2>';
+			<h1>', $this->title, '</h1>';
 
 		foreach ($this->_subtemplates as $template)
 			$template->html_main();
 
 		echo '
-			<form action="', $this->request_url, '" method="', $this->request_method, '" enctype="multipart/form-data">';
+			<form action="', $this->form->request_url, '" method="', $this->form->request_method, '" enctype="multipart/form-data">';
 
-		if (isset($this->content_above))
-			echo $this->content_above;
+		if (isset($this->form->content_above))
+			echo $this->form->content_above;
 
-		echo '
-				<dl>';
+		$this->missing = $this->form->getMissing();
+		$this->data = $this->form->getData();
 
-		foreach ($this->fields as $field_id => $field)
+		foreach ($this->form->getFields() as $field_id => $field)
 		{
 			// Either we have a blacklist
 			if (!empty($exclude) && in_array($field_id, $exclude))
@@ -62,107 +52,230 @@ class FormView extends SubTemplate
 		}
 
 		echo '
-				</dl>
 				<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">
-				<div style="clear: both">
-					<button type="submit" class="btn btn-primary">Save information</button>';
+				<div class="form-group">
+					<div class="offset-sm-2 col-sm-10">
+						<button type="submit" name="submit" class="btn btn-primary">', $this->form->getSubmitButtonCaption(), '</button>';
 
-		if (isset($this->content_below))
+		if (isset($this->form->content_below))
 			echo '
-					', $this->content_below;
+						', $this->form->content_below;
 
 		echo '
+					</div>
 				</div>
 			</form>';
-
-		if (!empty($this->title))
-			echo '
-			</div>';
 	}
 
-	protected function renderField($field_id, $field)
+	protected function renderField($field_id, array $field)
 	{
 		if (isset($field['before_html']))
-			echo '</dl>
-					', $field['before_html'], '
-				<dl>';
-
-		if ($field['type'] != 'checkbox' && isset($field['label']))
 			echo '
-					<dt class="cont_', $field_id, isset($field['tab_class']) ? ' target target-' . $field['tab_class'] : '', '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['label'], '</dt>';
-		elseif ($field['type'] === 'checkbox' && isset($field['header']))
-			echo '
-					<dt class="cont_', $field_id, isset($field['tab_class']) ? ' target target-' . $field['tab_class'] : '', '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['header'], '</dt>';
+				', $field['before_html'];
 
 		echo '
-					<dd class="cont_', $field_id, isset($field['dd_class']) ? ' ' . $field['dd_class'] : '', isset($field['tab_class']) ? ' target target-' . $field['tab_class'] : '', '">';
+				<div class="row mb-2">';
 
 		if (isset($field['before']))
 			echo $field['before'];
 
+		if ($field['type'] !== 'checkbox')
+			if (isset($field['label']))
+				echo '
+					<label class="col-sm-2 col-form-label" for="', $field_id, '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['label'], ':</label>
+					<div class="', isset($field['class']) ? $field['class'] : 'col-sm-6', '">';
+			else
+				echo '
+					<div class="offset-sm-2 ', isset($field['class']) ? $field['class'] : 'col-sm-6', '">';
+
 		switch ($field['type'])
 		{
 			case 'select':
-				echo '
-						<select name="', $field_id, '" id="', $field_id, '"', !empty($field['disabled']) ? ' disabled' : '', '>';
-
-				if (isset($field['placeholder']))
-					echo '
-							<option value="">', $field['placeholder'], '</option>';
-
-				foreach ($field['options'] as $value => $option)
-					echo '
-							<option value="', $value, '"', $this->data[$field_id] == $value ? ' selected' : '', '>', htmlentities($option), '</option>';
-
-				echo '
-						</select>';
+				$this->renderSelect($field_id, $field);
 				break;
 
 			case 'radio':
-				foreach ($field['options'] as $value => $option)
-					echo '
-						<input type="radio" name="', $field_id, '" value="', $value, '"', $this->data[$field_id] == $value ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', '> ', htmlentities($option);
+				$this->renderRadio($field_id, $field);
 				break;
 
 			case 'checkbox':
-				echo '
-						<label><input type="checkbox"', $this->data[$field_id] ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', ' name="', $field_id, '"> ', htmlentities($field['label']), '</label>';
+				$this->renderCheckbox($field_id, $field);
 				break;
 
 			case 'textarea':
-				echo '
-						<textarea name="', $field_id, '" id="', $field_id, '" cols="', isset($field['columns']) ? $field['columns'] : 40, '" rows="', isset($field['rows']) ? $field['rows'] : 4, '"', !empty($field['disabled']) ? ' disabled' : '', '>', $this->data[$field_id], '</textarea>';
+				$this->renderTextArea($field_id, $field);
 				break;
 
 			case 'color':
-				echo '
-						<input type="color" name="', $field_id, '" id="', $field_id, '" value="', htmlentities($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', '>';
+				$this->renderColor($field_id, $field);
 				break;
 
 			case 'numeric':
-				echo '
-						<input type="number"', isset($field['step']) ? ' step="' . $field['step'] . '"' : '', ' min="', isset($field['min_value']) ? $field['min_value'] : '0', '" max="', isset($field['max_value']) ? $field['max_value'] : '9999', '" name="', $field_id, '" id="', $field_id, '"', isset($field['size']) ? ' size="' . $field['size'] . '"' : '', isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '', ' value="', htmlentities($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', '>';
+				$this->renderNumeric($field_id, $field);
 				break;
 
 			case 'file':
-				if (!empty($this->data[$field_id]))
-					echo '<img src="', $this->data[$field_id], '" alt=""><br>';
+				$this->renderFile($field_id, $field);
+				break;
 
-				echo '
-						<input type="file" name="', $field_id, '" id="', $field_id, '"', !empty($field['disabled']) ? ' disabled' : '', '>';
+			case 'captcha':
+				$this->renderCaptcha($field_id, $field);
 				break;
 
 			case 'text':
 			case 'password':
 			default:
-				echo '
-						<input type="', $field['type'], '" name="', $field_id, '" id="', $field_id, '"', isset($field['size']) ? ' size="' . $field['size'] . '"' : '', isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '', ' value="', htmlentities($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', isset($field['trigger']) ? ' class="trigger-' . $field['trigger'] . '"' : '', '>';
+				$this->renderText($field_id, $field);
 		}
 
 		if (isset($field['after']))
 			echo ' ', $field['after'];
 
+		if ($field['type'] !== 'checkbox')
+			echo '
+					</div>';
+
 		echo '
-					</dd>';
+				</div>';
+	}
+
+	private function renderCaptcha($field_id, array $field)
+	{
+		echo '
+						<div class="g-recaptcha" data-sitekey="', RECAPTCHA_API_KEY, '"></div>
+						<script src="https://www.google.com/recaptcha/api.js"></script>';
+	}
+
+	private function renderCheckbox($field_id, array $field)
+	{
+		echo '
+					<div class="offset-sm-2 col-sm-10">
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox"', $this->data[$field_id] ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', ' name="', $field_id, '" id="check-', $field_id, '">
+							<label class="form-check-label" for="check-', $field_id, '">
+								', $field['label'], '
+							</label>
+						</div>
+					</div>';
+	}
+
+	private function renderColor($field_id, array $field)
+	{
+		echo '
+						<input class="form-control" type="color" name="', $field_id, '" id="', $field_id, '" value="', htmlspecialchars($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', '>';
+	}
+
+	private function renderFile($field_id, array $field)
+	{
+		if (!empty($this->data[$field_id]))
+			echo 'Currently using asset <tt>', $this->data[$field_id], '</tt>. Upload to overwrite.<br>';
+
+		echo '
+						<input class="form-control" type="file" name="', $field_id, '" id="', $field_id, '"', !empty($field['disabled']) ? ' disabled' : '', '>';
+	}
+
+	private function renderNumeric($field_id, array $field)
+	{
+		echo '
+						<input class="form-control" type="number"',
+							isset($field['step']) ? ' step="' . $field['step'] . '"' : '',
+							' min="', isset($field['min_value']) ? $field['min_value'] : '0', '"',
+							' max="', isset($field['max_value']) ? $field['max_value'] : '9999', '"',
+							' name="', $field_id, '" id="', $field_id, '"',
+							isset($field['size']) ? ' size="' . $field['size'] . '"' : '',
+							isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '',
+							' value="', htmlspecialchars($this->data[$field_id]), '"',
+							!empty($field['disabled']) ? ' disabled' : '', '>';
+	}
+
+	private function renderRadio($field_id, array $field)
+	{
+		foreach ($field['options'] as $value => $option)
+			echo '
+						<div class="form-check">
+							<input class="form-check-input" type="radio" name="', $field_id, '" id="radio-', $field_id, '-', $value, '" value="', $value, '"', $this->data[$field_id] == $value ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', '>
+							<label class="form-check-label" for="radio-', $field_id, '-', $value, '">
+								', htmlspecialchars($option), '
+							</label>
+						</div>';
+	}
+
+	private function renderSelect($field_id, array $field)
+	{
+		echo '
+						<select class="form-select" name="', $field_id, !empty($field['multiple']) ? '[]' : '',
+					'" id="', $field_id, '"',
+					!empty($field['disabled']) ? ' disabled' : '',
+					!empty($field['multiple']) ? ' multiple' : '',
+					!empty($field['size']) ? ' size="' . $field['size'] . '"' : '',
+					'>';
+
+		if (isset($field['placeholder']))
+			echo '
+							<option value="">', $field['placeholder'], '</option>';
+
+		foreach ($field['options'] as $key => $value)
+		{
+			if (is_array($value))
+			{
+				assert(empty($field['multiple']));
+				$this->renderSelectOptionGroup($field_id, $key, $value);
+			}
+			else
+				$this->renderSelectOption($field_id, $value, $key, !empty($field['multiple']));
+		}
+
+		echo '
+						</select>';
+	}
+
+	private function renderSelectOption($field_id, $label, $value, $multiple = false)
+	{
+		echo '
+							<option value="', $value, '"',
+							!$multiple && $this->data[$field_id] == $value ? ' selected' : '',
+							$multiple && in_array($value, $this->data[$field_id]) ? ' selected' : '',
+							'>', htmlspecialchars($label), '</option>';
+	}
+
+	private function renderSelectOptionGroup($field_id, $label, $options)
+	{
+		echo '
+							<optgroup label="', $label, '">';
+
+		foreach ($options as $value => $option)
+			$this->renderSelectOption($field_id, $option, $value);
+
+		echo '
+							</optgroup>';
+	}
+
+	private function renderText($field_id, array $field)
+	{
+		echo '
+						<input class="form-control" ',
+						'type="', $field['type'], '" ',
+						'name="', $field_id, '" ',
+						'id="', $field_id, '"',
+						isset($field['size']) ? ' size="' . $field['size'] . '"' : '',
+						isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '',
+						isset($this->data[$field_id]) ? ' value="' . htmlspecialchars($this->data[$field_id]) . '"' : '',
+						isset($field['placeholder']) ? ' placeholder="' . $field['placeholder'] . '"' : '',
+						!empty($field['disabled']) ? ' disabled' : '',
+						isset($field['trigger']) ? ' class="trigger-' . $field['trigger'] . '"' : '',
+						'>';
+	}
+
+	private function renderTextArea($field_id, array $field)
+	{
+		echo '
+						<textarea class="form-control' .
+							'" name="', $field_id,
+							'" id="', $field_id,
+							'" cols="', isset($field['columns']) ? $field['columns'] : 40,
+							'" rows="', isset($field['rows']) ? $field['rows'] : 4, '"',
+							isset($field['placeholder']) ? ' placeholder="' . $field['placeholder'] . '"' : '',
+							'"', !empty($field['disabled']) ? ' disabled' : '',
+							'>', $this->data[$field_id], '</textarea>';
 	}
 }
diff --git a/templates/PageIndexWidget.php b/templates/PageIndexWidget.php
new file mode 100644
index 0000000..a10070e
--- /dev/null
+++ b/templates/PageIndexWidget.php
@@ -0,0 +1,61 @@
+<?php
+/*****************************************************************************
+ * PageIndexWidget.php
+ * Contains the template that displays a page index.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+class PageIndexWidget extends Template
+{
+	private $index;
+	private string $class;
+
+	public function __construct(PageIndex $index)
+	{
+		$this->index = $index;
+		$this->class = $index->getPageIndexClass();
+	}
+
+	public function html_main()
+	{
+		self::paginate($this->index, $this->class);
+	}
+
+	public static function paginate(PageIndex $index, $class = null)
+	{
+		$page_index = $index->getPageIndex();
+		if (empty($page_index) || count($page_index) == 1)
+			return;
+
+		if (!isset($class))
+			$class = $index->getPageIndexClass();
+
+		echo '
+				<ul class="pagination', $class ? ' ' . $class : '', '">
+					<li class="page-item', empty($page_index['previous']) ? ' disabled' : '', '">',
+						'<a class="page-link"', !empty($page_index['previous']) ? ' href="' . $page_index['previous']['href'] . '"' : '', '>',
+						'&laquo; previous</a></li>';
+
+		foreach ($page_index as $key => $page)
+		{
+			if (!is_numeric($key))
+				continue;
+
+			if (!is_array($page))
+				echo '
+					<li class="page-item disabled"><a class="page-link">...</a></li>';
+			else
+				echo '
+					<li class="page-item', $page['is_selected'] ? ' active" aria-current="page' : '', '">',
+						'<a class="page-link" href="', $page['href'], '">', $page['index'], '</a></li>';
+		}
+
+		echo '
+					<li class="page-item', empty($page_index['next']) ? ' disabled' : '', '">',
+						'<a class="page-link"', !empty($page_index['next']) ? ' href="' . $page_index['next']['href'] . '"' : '', '>',
+						'next &raquo;</a></li>
+				</ul>';
+	}
+}
diff --git a/templates/Pagination.php b/templates/Pagination.php
deleted file mode 100644
index fea1df4..0000000
--- a/templates/Pagination.php
+++ /dev/null
@@ -1,64 +0,0 @@
-<?php
-/*****************************************************************************
- * Pagination.php
- * Contains the pagination template.
- *
- * Kabuki CMS (C) 2013-2016, Aaron van Geffen
- *****************************************************************************/
-
-class Pagination extends SubTemplate
-{
-	private $index;
-	private static $unique_index_count = 0;
-	private string $class;
-
-	public function __construct(PageIndex $index)
-	{
-		$this->index = $index;
-		$this->class = $index->getPageIndexClass();
-	}
-
-	protected function html_content()
-	{
-		$index = $this->index->getPageIndex();
-
-		echo '
-				<div class="table_pagination', !empty($this->class) ? ' ' . $this->class : '', '">
-					<ul>
-						<li class="first"><', !empty($index['previous']) ? 'a href="' . $index['previous']['href'] . '"' : 'span', '>&laquo; previous</', !empty($index['previous']) ? 'a' : 'span', '></li>';
-
-		$num_wildcards = 0;
-		foreach ($index as $key => $page)
-		{
-			if (!is_numeric($key))
-				continue;
-
-			if (!is_array($page))
-			{
-				$num_wildcards++;
-				echo '
-						<li class="page-padding" onclick="javascript:promptGoToPage(', self::$unique_index_count, ')"><span>...</span></li>';
-			}
-			else
-				echo '
-						<li class="page-number', $page['is_selected'] ? ' active' : '', '"><a href="', $page['href'], '">', $page['index'], '</a></li>';
-		}
-
-		echo '
-						<li class="last"><', !empty($index['next']) ? 'a href="' . $index['next']['href'] . '"' : 'span', '>next &raquo;</', !empty($index['next']) ? 'a' : 'span', '></li>
-					</ul>
-				</div>';
-
-		if ($num_wildcards)
-		{
-			echo '
-			<script type="text/javascript">
-				var page_index_', self::$unique_index_count++, ' = {
-					wildcard_url: "', $this->index->getLink("%d"), '",
-					num_pages: ', $this->index->getNumberOfPages(), ',
-					per_page: ', $this->index->getItemsPerPage(), '
-				};
-			</script>';
-		}
-	}
-}
diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php
index 2ec9795..c988e54 100644
--- a/templates/PhotosIndex.php
+++ b/templates/PhotosIndex.php
@@ -45,7 +45,7 @@ class PhotosIndex extends SubTemplate
 	protected function html_content()
 	{
 		echo '
-			<div class="tiled_grid">';
+			<div class="tiled_grid clearfix">';
 
 		for ($i = $this->row_limit; $i > 0 && $row = $this->mosaic->getRow(); $i--)
 		{
diff --git a/templates/TabularData.php b/templates/TabularData.php
index 587d4f0..86f17de 100644
--- a/templates/TabularData.php
+++ b/templates/TabularData.php
@@ -3,56 +3,73 @@
  * TabularData.php
  * Contains the template that displays tabular data.
  *
- * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
  *****************************************************************************/
 
 class TabularData extends SubTemplate
 {
-	private Pagination $pager;
 	private GenericTable $_t;
 
 	public function __construct(GenericTable $table)
 	{
 		$this->_t = $table;
-
-		$pageIndex = $table->getPageIndex();
-		if ($pageIndex)
-			$this->pager = new Pagination($pageIndex);
 	}
 
 	protected function html_content()
 	{
-		echo '
-			<div class="admin_box">';
-
 		$title = $this->_t->getTitle();
 		if (!empty($title))
+		{
+			$titleclass = $this->_t->getTitleClass();
 			echo '
-				<h2>', $title, '</h2>';
+			<div class="generic-table', !empty($titleclass) ? ' ' . $titleclass : '', '">
+				<h1>', htmlspecialchars($title), '</h1>';
+		}
 
-		// Showing a page index?
-		if (isset($this->pager))
-			$this->pager->html_content();
+		foreach ($this->_subtemplates as $template)
+			$template->html_main();
 
-		// Maybe even a small form?
-		if (isset($this->_t->form_above))
-			$this->showForm($this->_t->form_above);
+		// Showing an inline form?
+		$pager = $this->_t->getPageIndex();
+		if (!empty($pager) || isset($this->_t->form_above))
+		{
+			echo '
+				<div class="row clearfix justify-content-end">';
+
+			// Page index?
+			if (!empty($pager))
+				PageIndexWidget::paginate($pager);
+
+			// Form controls?
+			if (isset($this->_t->form_above))
+				$this->showForm($this->_t->form_above);
+
+			echo '
+				</div>';
+		}
+
+		$tableClass = $this->_t->getTableClass();
+		if ($tableClass)
+			echo '
+			<div class="', $tableClass, '">';
 
 		// Build the table!
 		echo '
-				<table class="table table-striped">
+				<table class="table table-striped table-condensed">
 					<thead>
 						<tr>';
 
-		// Show the table's headers.
-		foreach ($this->_t->getHeader() as $th)
+		// Show all headers in their full glory!
+		$header = $this->_t->getHeader();
+		foreach ($header as $th)
 		{
 			echo '
 							<th', (!empty($th['width']) ? ' width="' . $th['width'] . '"' : ''), (!empty($th['class']) ? ' class="' . $th['class'] . '"' : ''), ($th['colspan'] > 1 ? ' colspan="' . $th['colspan'] . '"' : ''), ' scope="', $th['scope'], '">',
 							$th['href'] ? '<a href="' . $th['href'] . '">' . $th['label'] . '</a>' : $th['label'];
 
-			if ($th['sort_mode'] )
-				echo ' ', $th['sort_mode'] === 'up' ? '&uarr;' : '&darr;';
+			if ($th['sort_mode'])
+				echo ' <i class="bi bi-caret-' . ($th['sort_mode'] === 'down' ? 'down' : 'up') . '-fill"></i>';
 
 			echo '</th>';
 		}
@@ -62,7 +79,7 @@ class TabularData extends SubTemplate
 					</thead>
 					<tbody>';
 
-		// Show the table's body.
+		// The body is what we came to see!
 		$body = $this->_t->getBody();
 		if (is_array($body))
 		{
@@ -72,51 +89,134 @@ class TabularData extends SubTemplate
 						<tr', (!empty($tr['class']) ? ' class="' . $tr['class'] . '"' : ''), '>';
 
 				foreach ($tr['cells'] as $td)
+				{
 					echo '
-							<td', (!empty($td['width']) ? ' width="' . $td['width'] . '"' : ''), '>', $td['value'], '</td>';
+							<td', (!empty($td['width']) ? ' width="' . $td['width'] . '"' : ''), '>';
+
+					if (!empty($td['class']))
+						echo '<span class="', $td['class'], '">', $td['value'], '</span>';
+					else
+						echo $td['value'];
+
+					echo '</td>';
+				}
 
 				echo '
 						</tr>';
 			}
 		}
+		// !!! Sum colspan!
 		else
 			echo '
 						<tr>
-							<td colspan="', count($this->_t->getHeader()), '">', $body, '</td>
+							<td colspan="', count($header), '" class="fullwidth">', $body, '</td>
 						</tr>';
 
 		echo '
 					</tbody>
 				</table>';
 
-		// Maybe another small form?
-		if (isset($this->_t->form_below))
-			$this->showForm($this->_t->form_below);
+		if ($tableClass)
+			echo '
+			</div>';
 
-		// Showing a page index?
-		if (isset($this->pager))
-			$this->pager->html_content();
+		// Showing an inline form?
+		if (!empty($pager) || isset($this->_t->form_below))
+		{
+			echo '
+				<div class="row clearfix justify-content-end">';
 
-		echo '
+			// Page index?
+			if (!empty($pager))
+				PageIndexWidget::paginate($pager);
+
+			// Form controls?
+			if (isset($this->_t->form_below))
+				$this->showForm($this->_t->form_below);
+
+			echo '
+				</div>';
+		}
+
+		if (!empty($title))
+			echo '
 			</div>';
 	}
 
 	protected function showForm($form)
 	{
 		echo '
-				<form action="', $form['action'], '" method="', $form['method'], '" class="table_form ', $form['class'], '">';
+			<form action="', $form['action'], '" method="', $form['method'], '" class="', $form['class'], '">';
+
+		if (!empty($form['is_group']))
+			echo '
+				<div class="input-group">';
 
 		if (!empty($form['fields']))
+		{
 			foreach ($form['fields'] as $name => $field)
-				echo '
-					<input name="', $name, '" type="', $field['type'], '" placeholder="', $field['placeholder'], '"', isset($field['class']) ? ' class="' . $field['class'] . '"' : '', isset($field['value']) ? ' value="' . $field['value'] . '"' : '', '>';
+			{
+				if ($field['type'] === 'select')
+				{
+					echo '
+					<select class="form-select" name="', $name, '"', (isset($field['onchange']) ? ' onchange="' . $field['onchange'] . '"' : ''), '>';
+
+					foreach ($field['values'] as $value => $caption)
+					{
+						if (!is_array($caption))
+						{
+							echo '
+						<option value="', $value, '"', $value === $field['selected'] ? ' selected' : '', '>', $caption, '</option>';
+						}
+						else
+						{
+							$label = $value;
+							$options = $caption;
+
+							echo '
+						<optgroup label="', $label, '">';
+
+							foreach ($options as $value => $caption)
+							{
+								echo '
+							<option value="', $value, '"', $value === $field['selected'] ? ' selected' : '', '>', $caption, '</option>';
+							}
+
+							echo '
+						</optgroup>';
+						}
+					}
+
+					echo '
+					</select>';
+				}
+				else
+					echo '
+					<input name="', $name, '" id="field_', $name, '" type="', $field['type'], '" placeholder="', $field['placeholder'], '" class="form-control', isset($field['class']) ? ' ' . $field['class'] : '', '"', isset($field['value']) ? ' value="' . htmlspecialchars($field['value']) . '"' : '', '>';
+
+				if (isset($field['html_after']))
+					echo $field['html_after'];
+			}
+		}
+
+		echo '
+					<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">';
 
 		if (!empty($form['buttons']))
 			foreach ($form['buttons'] as $name => $button)
+			{
 				echo '
-					<input name="', $name, '" type="', $button['type'], '" value="', $button['caption'], '" class="btn', isset($button['class']) ? ' ' . $button['class'] . '' : '', '">';
+					<button class="btn ', isset($button['class']) ? $button['class'] : 'btn-primary', '" type="', $button['type'], '" name="', $name, '">', $button['caption'], '</button>';
+
+				if (isset($button['html_after']))
+					echo $button['html_after'];
+			}
+
+		if (!empty($form['is_group']))
+			echo '
+				</div>';
 
 		echo '
-				</form>';
+			</form>';
 	}
 }
-- 
2.46.0


From 0366df9b5fa55682c1eedf2484557a76349fb580 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:30:02 +0100
Subject: [PATCH 04/79] Alerts: replace 'error' class with 'danger'

---
 controllers/EditAlbum.php     |  4 ++--
 controllers/EditTag.php       |  4 ++--
 controllers/EditUser.php      | 12 ++++++------
 controllers/Login.php         |  2 +-
 controllers/ResetPassword.php |  4 ++--
 models/Dispatcher.php         |  2 +-
 6 files changed, 14 insertions(+), 14 deletions(-)

diff --git a/controllers/EditAlbum.php b/controllers/EditAlbum.php
index 26e2c6a..5888876 100644
--- a/controllers/EditAlbum.php
+++ b/controllers/EditAlbum.php
@@ -123,7 +123,7 @@ class EditAlbum extends HTMLController
 
 			// Anything missing?
 			if (!empty($form->getMissing()))
-				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'error'));
+				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
 
 			$data = $form->getData();
 
@@ -140,7 +140,7 @@ class EditAlbum extends HTMLController
 				$data['kind'] = 'Album';
 				$newTag = Tag::createNew($data);
 				if ($newTag === false)
-					return $formview->adopt(new Alert('Cannot create this album', 'Something went wrong while creating the album...', 'error'));
+					return $formview->adopt(new Alert('Cannot create this album', 'Something went wrong while creating the album...', 'danger'));
 
 				if (isset($_POST['submit_and_new']))
 				{
diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index 24126c3..4b42926 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -115,7 +115,7 @@ class EditTag extends HTMLController
 
 			// Anything missing?
 			if (!empty($form->getMissing()))
-				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'error'));
+				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
 
 			$data = $form->getData();
 
@@ -127,7 +127,7 @@ class EditTag extends HTMLController
 			{
 				$return = Tag::createNew($data);
 				if ($return === false)
-					return $formview->adopt(new Alert('Cannot create this tag', 'Something went wrong while creating the tag...', 'error'));
+					return $formview->adopt(new Alert('Cannot create this tag', 'Something went wrong while creating the tag...', 'danger'));
 
 				if (isset($_POST['submit_and_new']))
 				{
diff --git a/controllers/EditUser.php b/controllers/EditUser.php
index bbce589..c5f6578 100644
--- a/controllers/EditUser.php
+++ b/controllers/EditUser.php
@@ -129,7 +129,7 @@ class EditUser extends HTMLController
 
 			// Anything missing?
 			if (!empty($form->getMissing()))
-				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'error'));
+				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
 
 			$data = $form->getData();
 
@@ -150,18 +150,18 @@ class EditUser extends HTMLController
 
 			// If it looks like an e-mail address...
 			if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress']))
-				return $formview->adopt(new Alert('Email addresses invalid', 'The email address you entered is not a valid email address.', 'error'));
+				return $formview->adopt(new Alert('Email addresses invalid', 'The email address you entered is not a valid email address.', 'danger'));
 			// Check whether email address is already linked to an account in the database -- just not to the account we happen to be editing, of course.
 			elseif (!empty($data['emailaddress']) && Member::exists($data['emailaddress']) && !($id_user && $user->getEmailAddress() == $data['emailaddress']))
-				return $formview->adopt(new Alert('Email address already in use', 'Another account is already using the e-mail address you entered.', 'error'));
+				return $formview->adopt(new Alert('Email address already in use', 'Another account is already using the e-mail address you entered.', 'danger'));
 
 			// Setting passwords? We'll need two!
 			if (!$id_user || !empty($data['password1']) && !empty($data['password2']))
 			{
 				if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1']))
-					return $formview->adopt(new Alert('Password not acceptable', 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).', 'error'));
+					return $formview->adopt(new Alert('Password not acceptable', 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).', 'danger'));
 				elseif ($data['password1'] !== $data['password2'])
-					return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'error'));
+					return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'danger'));
 				else
 					$data['password'] = $data['password1'];
 
@@ -173,7 +173,7 @@ class EditUser extends HTMLController
 			{
 				$return = Member::createNew($data);
 				if ($return === false)
-					return $formview->adopt(new Alert('Cannot create this user', 'Something went wrong while creating the user...', 'error'));
+					return $formview->adopt(new Alert('Cannot create this user', 'Something went wrong while creating the user...', 'danger'));
 
 				if (isset($_POST['submit_and_new']))
 				{
diff --git a/controllers/Login.php b/controllers/Login.php
index 46e5da6..d91785d 100644
--- a/controllers/Login.php
+++ b/controllers/Login.php
@@ -44,7 +44,7 @@ class Login extends HTMLController
 		parent::__construct('Log in - ' . SITE_TITLE);
 		$form = new LogInForm('Log in');
 		if ($login_error)
-			$form->adopt(new Alert('', 'Invalid email address or password.', 'error'));
+			$form->adopt(new Alert('', 'Invalid email address or password.', 'danger'));
 
 		// Tried anything? Be helpful, at least.
 		if (isset($_POST['emailaddress']))
diff --git a/controllers/ResetPassword.php b/controllers/ResetPassword.php
index 7153498..24fa7b6 100644
--- a/controllers/ResetPassword.php
+++ b/controllers/ResetPassword.php
@@ -48,7 +48,7 @@ class ResetPassword extends HTMLController
 					exit;
 				}
 				else
-					$form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'error'));
+					$form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'danger'));
 		 	}
 		}
 		else
@@ -63,7 +63,7 @@ class ResetPassword extends HTMLController
 				$id_user = Authentication::getUserid(trim($_POST['emailaddress']));
 				if ($id_user === false)
 				{
-					$form->adopt(new Alert('Invalid email address', 'The email address you provided could not be found in our system. Please try again.', 'error'));
+					$form->adopt(new Alert('Invalid email address', 'The email address you provided could not be found in our system. Please try again.', 'danger'));
 					return;
 				}
 
diff --git a/models/Dispatcher.php b/models/Dispatcher.php
index d5688ab..8280797 100644
--- a/models/Dispatcher.php
+++ b/models/Dispatcher.php
@@ -50,7 +50,7 @@ class Dispatcher
 	public static function kickGuest($title = null, $message = null)
 	{
 		$form = new LogInForm('Log in');
-		$form->adopt(new Alert($title ?? '', $message ?? 'You need to be logged in to view this page.', 'error'));
+		$form->adopt(new Alert($title ?? '', $message ?? 'You need to be logged in to view this page.', 'danger'));
 		$form->setRedirectUrl($_SERVER['REQUEST_URI']);
 
 		$page = new MainTemplate('Login required');
-- 
2.46.0


From 307d34430a18118273873904b6154261175ca60a Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:37:59 +0100
Subject: [PATCH 05/79] SubTemplate: use SubTemplates for boxed content only

---
 public/css/default.css       | 22 +++++++++++++++-------
 templates/AdminBar.php       |  4 ++--
 templates/AlbumButtonBox.php |  4 ++--
 templates/AlbumHeaderBox.php |  4 ++--
 templates/AlbumIndex.php     |  4 ++--
 templates/MediaUploader.php  |  6 +++---
 templates/PhotoPage.php      |  4 ++--
 templates/PhotosIndex.php    |  4 ++--
 templates/SubTemplate.php    | 24 +++++++++++++++++++++++-
 9 files changed, 53 insertions(+), 23 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index f4ecab9..510683d 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -8,18 +8,15 @@
 @import url(//fonts.googleapis.com/css?family=Open+Sans:400,400italic,700,700italic);
 
 @font-face {
-		font-family: 'Invaders';
-		src: url('fonts/invaders.ttf') format('truetype');
-		font-weight: normal;
-		font-style: normal;
+	font-family: 'Invaders';
+	src: url('fonts/invaders.ttf') format('truetype');
+	font-weight: normal;
+	font-style: normal;
 }
 
 body {
 	font-family: "Open Sans", sans-serif;
-	padding: 0 0 3em;
-	margin: 0;
 	background: #aaa 0 -50% fixed;
-	background-image: radial-gradient(ellipse at top, #ccc 0%, #aaa 55%, #333 100%);
 }
 
 #wrapper, header {
@@ -94,6 +91,17 @@ ul#nav li a:hover {
 }
 
 
+/* Content boxes
+------------------*/
+.content-box {
+	background-color: #fff;
+	margin: 0 auto 2rem;
+	padding: 2rem;
+	border-radius: 0.5rem;
+	box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
+}
+
+
 /* Tiled grid
 ---------------*/
 .tiled_header {
diff --git a/templates/AdminBar.php b/templates/AdminBar.php
index 65662fe..daa81a8 100644
--- a/templates/AdminBar.php
+++ b/templates/AdminBar.php
@@ -6,11 +6,11 @@
  * Kabuki CMS (C) 2013-2015, Aaron van Geffen
  *****************************************************************************/
 
-class AdminBar extends SubTemplate
+class AdminBar extends Template
 {
 	private $extra_items = [];
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 			<div id="admin_bar">
diff --git a/templates/AlbumButtonBox.php b/templates/AlbumButtonBox.php
index d18ef31..4126b72 100644
--- a/templates/AlbumButtonBox.php
+++ b/templates/AlbumButtonBox.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2016, Aaron van Geffen
  *****************************************************************************/
 
-class AlbumButtonBox extends SubTemplate
+class AlbumButtonBox extends Template
 {
 	private $buttons;
 
@@ -15,7 +15,7 @@ class AlbumButtonBox extends SubTemplate
 		$this->buttons = $buttons;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 			<div class="album_button_box">';
diff --git a/templates/AlbumHeaderBox.php b/templates/AlbumHeaderBox.php
index 0de991d..7dfccf1 100644
--- a/templates/AlbumHeaderBox.php
+++ b/templates/AlbumHeaderBox.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2016, Aaron van Geffen
  *****************************************************************************/
 
-class AlbumHeaderBox extends SubTemplate
+class AlbumHeaderBox extends Template
 {
 	private $back_link_title;
 	private $back_link;
@@ -21,7 +21,7 @@ class AlbumHeaderBox extends SubTemplate
 		$this->back_link_title = $back_link_title;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 			<div class="album_title_box">
diff --git a/templates/AlbumIndex.php b/templates/AlbumIndex.php
index 7fdcf64..56635a4 100644
--- a/templates/AlbumIndex.php
+++ b/templates/AlbumIndex.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2015, Aaron van Geffen
  *****************************************************************************/
 
-class AlbumIndex extends SubTemplate
+class AlbumIndex extends Template
 {
 	protected $albums;
 	protected $show_edit_buttons;
@@ -23,7 +23,7 @@ class AlbumIndex extends SubTemplate
 		$this->show_labels = $show_labels;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 			<div class="tiled_grid clearfix">';
diff --git a/templates/MediaUploader.php b/templates/MediaUploader.php
index 008cfc9..d577ec7 100644
--- a/templates/MediaUploader.php
+++ b/templates/MediaUploader.php
@@ -18,14 +18,14 @@ class MediaUploader extends SubTemplate
 	protected function html_content()
 	{
 		echo '
-			<form action="', BASEURL, '/uploadmedia/?tag=', $this->tag->id_tag, '" class="boxed_content" method="post" enctype="multipart/form-data">
+			<form action="', BASEURL, '/uploadmedia/?tag=', $this->tag->id_tag, '" method="post" enctype="multipart/form-data">
 				<h2>Upload new photos to &quot;', $this->tag->tag, '&quot;</h2>
 				<div>
 					<h3>Select files</h3>
-					<input type="file" id="upload_queue" name="uploads[]" multiple>
+					<input class="form-control" type="file" id="upload_queue" name="uploads[]" multiple>
 				</div>
 				<div>
-					<input name="save" id="photo_submit" type="submit" value="Upload the lot">
+					<input class="btn btn-primary" name="save" id="photo_submit" type="submit" value="Upload the lot">
 				</div>
 				<div id="upload_preview_area">
 				</div>
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index 3ce2b55..ded285f 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2016, Aaron van Geffen
  *****************************************************************************/
 
-class PhotoPage extends SubTemplate
+class PhotoPage extends Template
 {
 	protected $photo;
 	private $exif;
@@ -34,7 +34,7 @@ class PhotoPage extends SubTemplate
 		$this->is_asset_owner = $flag;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		$this->photoNav();
 		$this->photo();
diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php
index c988e54..25255c5 100644
--- a/templates/PhotosIndex.php
+++ b/templates/PhotosIndex.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2015, Aaron van Geffen
  *****************************************************************************/
 
-class PhotosIndex extends SubTemplate
+class PhotosIndex extends Template
 {
 	protected $mosaic;
 	protected $show_edit_buttons;
@@ -42,7 +42,7 @@ class PhotosIndex extends SubTemplate
 		$this->show_labels = $show_labels;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 			<div class="tiled_grid clearfix">';
diff --git a/templates/SubTemplate.php b/templates/SubTemplate.php
index 75030de..79f0ed0 100644
--- a/templates/SubTemplate.php
+++ b/templates/SubTemplate.php
@@ -8,10 +8,32 @@
 
 abstract class SubTemplate extends Template
 {
+	protected $_class = 'content-box container';
+	protected $_id;
+	protected $_title;
+
+	public function __construct($title = '')
+	{
+		$this->_title = $title;
+	}
+
 	public function html_main()
 	{
-		echo $this->html_content();
+		echo '
+			<div class="', $this->_class, '"', isset($this->_id) ? ' id="' . $this->_id . '"' : '', '>',
+				$this->html_content(), '
+			</div>';
 	}
 
 	abstract protected function html_content();
+
+	public function setClassName($className)
+	{
+		$this->_class = $className;
+	}
+
+	public function setDOMId($id)
+	{
+		$this->_id = $id;
+	}
 }
-- 
2.46.0


From 2d1a299fe0874d331a325be721850a289353fe49 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:44:36 +0100
Subject: [PATCH 06/79] Replace login and password reset templates

---
 templates/ForgotPasswordForm.php | 24 +++++++++------
 templates/LogInForm.php          | 52 ++++++++++++++++++++------------
 templates/PasswordResetForm.php  | 36 ++++++++++++----------
 3 files changed, 67 insertions(+), 45 deletions(-)

diff --git a/templates/ForgotPasswordForm.php b/templates/ForgotPasswordForm.php
index 7a50660..447ebeb 100644
--- a/templates/ForgotPasswordForm.php
+++ b/templates/ForgotPasswordForm.php
@@ -11,19 +11,25 @@ class ForgotPasswordForm extends SubTemplate
 	protected function html_content()
 	{
 		echo '
-						<div class="boxed_content">
-							<h2>Password reset procedure</h2>';
+				<h1>Password reset procedure</h1>';
 
 		foreach ($this->_subtemplates as $template)
 			$template->html_main();
 
 		echo '
-							<p>Please fill in the email address you used to sign up in the form below. You will be sent a reset link to your email address.</p>
-							<form class="form-horizontal" action="', BASEURL, '/resetpassword/?step=1" method="post">
-								<label class="control-label" for="field_emailaddress">E-mail address:</label><br>
-								<input type="text" id="field_emailaddress" name="emailaddress">
-								<button type="submit" class="btn btn-primary">Send mail</button>
-							</form>
-						</div>';
+				<p class="mt-3">Please fill in the email address you used to sign up in the form below. We will send a reset link to your email address.</p>
+				<form action="', BASEURL, '/resetpassword/?step=1" method="post">
+					<div class="row">
+						<label class="col-sm-2 col-form-label" for="field_emailaddress">E-mail address:</label>
+						<div class="col-sm-4">
+							<input type="text" class="form-control" id="field_emailaddress" name="emailaddress">
+						</div>
+					</div>
+					<div class="row mt-3">
+						<div class="offset-sm-2 col-sm-2">
+							<button type="submit" class="btn btn-primary">Send mail</button>
+						</div>
+					</div>
+				</form>';
 	}
 }
diff --git a/templates/LogInForm.php b/templates/LogInForm.php
index c2b8835..0c28e70 100644
--- a/templates/LogInForm.php
+++ b/templates/LogInForm.php
@@ -11,45 +11,57 @@ class LogInForm extends SubTemplate
 	private $redirect_url = '';
 	private $emailaddress = '';
 
+	protected $_class = 'content-box container w-50';
+
 	public function setRedirectUrl($url)
 	{
-		$_SESSION['login_url'] = $url;
 		$this->redirect_url = $url;
 	}
 
 	public function setEmail($addr)
 	{
-		$this->emailaddress = htmlentities($addr);
+		$this->emailaddress = htmlspecialchars($addr);
 	}
 
 	protected function html_content()
 	{
-		echo '
-			<form action="', BASEURL, '/login/" method="post" id="login">
-				<h3>Log in</h3>';
+		if (!empty($this->_title))
+			echo '
+						<h1 class="mb-4">Log in to your account</h1>';
 
-		foreach ($this->_subtemplates as $template)
-			$template->html_main();
+		if (!empty($this->_subtemplates))
+		{
+			foreach ($this->_subtemplates as $template)
+				$template->html_main();
+		}
 
 		echo '
-				<dl>
-					<dt><label for="field_emailaddress">E-mail address:</label></dt>
-					<dd><input type="text" id="field_emailaddress" name="emailaddress" tabindex="1" value="', $this->emailaddress, '" autofocus></dd>
-
-					<dt><label for="field_password">Password:</label></dt>
-					<dd><input type="password" id="field_password" name="password" tabindex="2"></dd>
-				</dl>';
+						<form class="mt-4" action="', BASEURL, '/login/" method="post">
+							<div class="row">
+								<label class="col-sm-3 col-form-label" for="field_emailaddress">E-mail address:</label>
+								<div class="col-sm">
+									<input type="text" class="form-control" id="field_emailaddress" name="emailaddress" value="', $this->emailaddress, '">
+								</div>
+							</div>
+							<div class="row mt-3">
+								<label class="col-sm-3 col-form-label" for="field_password">Password:</label>
+								<div class="col-sm">
+									<input type="password" class="form-control" id="field_password" name="password">
+								</div>
+							</div>';
 
 		// Throw in a redirect url if asked for.
 		if (!empty($this->redirect_url))
 			echo '
-				<input type="hidden" name="redirect_url" value="', base64_encode($this->redirect_url), '">';
+							<input type="hidden" name="redirect_url" value="', base64_encode($this->redirect_url), '">';
 
 		echo '
-				<a href="', BASEURL, '/resetpassword/">Forgotten your password?</a>
-				<div class="buttonstrip">
-					<button type="submit" class="btn btn-primary" id="field_login" name="login" tabindex="3">Log in</button>
-				</div>
-			</form>';
+							<div class="mt-4">
+								<div class="offset-sm-3 col-sm-9">
+									<button type="submit" class="btn btn-primary">Sign in</button>
+									<a class="btn btn-light" href="', BASEURL, '/resetpassword/" style="margin-left: 1em">Forgotten your password?</a>
+								</div>
+							</div>
+						</form>';
 	}
 }
diff --git a/templates/PasswordResetForm.php b/templates/PasswordResetForm.php
index 4bc9c5e..2393bd5 100644
--- a/templates/PasswordResetForm.php
+++ b/templates/PasswordResetForm.php
@@ -20,27 +20,31 @@ class PasswordResetForm extends SubTemplate
 	protected function html_content()
 	{
 		echo '
-						<div class="boxed_content">
-							<h2>Password reset procedure</h2>';
+					<h1 class="mb-4">Password reset procedure</h1>';
 
 		foreach ($this->_subtemplates as $template)
 			$template->html_main();
 
 		echo '
-							<p>You have successfully confirmed your identify. Please use the form below to set a new password.</p>
-							<form class="form-horizontal" action="', BASEURL, '/resetpassword/?step=2&amp;email=', rawurlencode($this->email), '&amp;key=', $this->key, '" method="post">
-								<p>
-									<label class="control-label" for="field_password1">New password:</label>
-									<input type="password" id="field_password1" name="password1">
-								</p>
-
-								<p>
-									<label class="control-label" for="field_password2">Repeat new password:</label>
-									<input type="password" id="field_password2" name="password2">
-								</p>
-
+					<p>You have successfully confirmed your identify. Please use the form below to set a new password.</p>
+					<form action="', BASEURL, '/resetpassword/?step=2&amp;email=', rawurlencode($this->email), '&amp;key=', $this->key, '" method="post">
+						<div class="row mt-3">
+							<label class="col-sm-2 col-form-label" for="field_password1">New password:</label>
+							<div class="col-sm-3">
+								<input type="password" class="form-control" id="field_password1" name="password1">
+							</div>
+						</div>
+						<div class="row mt-3">
+							<label class="col-sm-2 col-form-label" for="field_password2">Repeat new password:</label>
+							<div class="col-sm-3">
+								<input type="password" class="form-control" id="field_password2" name="password2">
+							</div>
+						</div>
+						<div class="row mt-3">
+							<div class="offset-sm-2 col-sm-2">
 								<button type="submit" class="btn btn-primary">Reset password</button>
-							</form>
-						</div>';
+							</div>
+						</div>
+					</form>';
 	}
 }
-- 
2.46.0


From cf31f0af074933ddff62161ccfb09d2f1c4a5d60 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:51:12 +0100
Subject: [PATCH 07/79] Replace more custom button classes with Bootstrap
 counterparts

---
 public/css/default.css       | 32 --------------------------------
 templates/AlbumButtonBox.php |  2 +-
 templates/PhotoPage.php      |  2 +-
 3 files changed, 2 insertions(+), 34 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 510683d..b92ea4b 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -280,29 +280,12 @@ ul#nav li a:hover {
 	margin-bottom: 20px;
 }
 .album_button_box > a {
-	background: #fff;
 	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-	display: inline-block;
-	float: left;
-	font-size: 1em;
 	padding: 8px 10px;
 	margin-left: 12px;
 }
 
 
-/* Generic boxed content
---------------------------*/
-.boxed_content {
-	background: #fff;
-	padding: 25px;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-}
-.boxed_content h2 {
-	font: 300 24px "Open Sans", sans-serif;
-	margin: 0 0 0.2em;
-}
-
-
 /* Error pages
 ----------------*/
 .errormsg p {
@@ -333,21 +316,6 @@ footer a {
 }
 
 
-/* Input
-----------*/
-.btn-red {
-	background: #eebbaa;
-	border-color: #cc9988;
-}
-.btn-red:hover, .btn-red:focus {
-	border-color: #bb7766;
-	color: #000;
-}
-.btn-red:focus:not(:active) {
-	box-shadow: 0px 0px 0px 2px rgba(241, 70, 104, 0.25);
-}
-
-
 /* Login box styles
 ---------------------*/
 #login {
diff --git a/templates/AlbumButtonBox.php b/templates/AlbumButtonBox.php
index 4126b72..3956ffb 100644
--- a/templates/AlbumButtonBox.php
+++ b/templates/AlbumButtonBox.php
@@ -22,7 +22,7 @@ class AlbumButtonBox extends Template
 
 		foreach ($this->buttons as $button)
 			echo '
-				<a href="', $button['url'], '">', $button['caption'], '</a>';
+				<a class="btn btn-light" href="', $button['url'], '">', $button['caption'], '</a>';
 
 		echo '
 			</div>';
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index ded285f..dc24134 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -242,7 +242,7 @@ class PhotoPage extends Template
 		echo '
 				<div id=user_actions_box>
 					<h3>Actions</h3>
-					<a class="btn btn-red" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete</a>
+					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete</a>
 				</div>';
 	}
 }
-- 
2.46.0


From a9a2c64d81be6eea7f2050f43232cedd84e5083c Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 13:57:57 +0100
Subject: [PATCH 08/79] PhotoPage: replace custom sub-photo boxes with generic
 equivalents

---
 public/css/default.css  | 26 --------------------------
 templates/PhotoPage.php | 22 +++++++++++++++-------
 2 files changed, 15 insertions(+), 33 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index b92ea4b..541f3e6 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -421,14 +421,6 @@ a#previous_photo:hover, a#next_photo:hover {
 	font-size: 16px;
 }
 
-#sub_photo {
-	background: #fff;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-	float: left;
-	padding: 2%;
-	margin: 25px 3.5% 25px 0;
-	width: 68.5%;
-}
 #sub_photo #tag_list {
 	list-style: none;
 	margin: 1em 0;
@@ -446,15 +438,6 @@ a#previous_photo:hover, a#next_photo:hover {
 }
 
 
-#photo_exif_box {
-	background: #fff;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-	margin: 25px 0 25px 0;
-	overflow: auto;
-	padding: 2%;
-	float: right;
-	width: 20%;
-}
 #photo_exif_box dt {
 	font-weight: bold;
 	float: left;
@@ -469,15 +452,6 @@ a#previous_photo:hover, a#next_photo:hover {
 	margin: 0;
 }
 
-#user_actions_box {
-	background: #fff;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-	float: left;
-	margin: 25px 0 25px 0;
-	overflow: auto;
-	padding: 2%;
-	width: 20%;
-}
 
 /* Responsive: smartphone in portrait
 ---------------------------------------*/
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index dc24134..1c0bd5c 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -40,21 +40,27 @@ class PhotoPage extends Template
 		$this->photo();
 
 		echo '
-				<div id="sub_photo">
-					<h2 class="entry-title">', $this->photo->getTitle(), '</h2>';
+				<div class="row mt-5">
+					<div class="col-8">
+						<div id="sub_photo" class="content-box">
+							<h2 class="entry-title">', $this->photo->getTitle(), '</h2>';
 
 		$this->taggedPeople();
 		$this->linkNewTags();
 
 		echo '
-				</div>';
+						</div>
+					</div>
+					<div class="col-4">';
 
 		$this->photoMeta();
 
-		if($this->is_asset_owner)
+		if ($this->is_asset_owner)
 			$this->addUserActions();
 
 		echo '
+					</div>
+				</div>
 				<script type="text/javascript" src="', BASEURL, '/js/photonav.js"></script>';
 	}
 
@@ -94,7 +100,7 @@ class PhotoPage extends Template
 	private function photoMeta()
 	{
 		echo '
-				<div id="photo_exif_box">
+				<div id="photo_exif_box" class="content-box clearfix">
 					<h3>EXIF</h3>
 					<dl class="photo_meta">';
 
@@ -171,7 +177,9 @@ class PhotoPage extends Template
 		echo '
 				<div>
 					<h3>Link tags</h3>
-					<p style="position: relative"><input type="text" id="new_tag" placeholder="Type to link a new tag"></p>
+					<p style="position: relative">
+						<input class="form-control w-auto" type="text" id="new_tag" placeholder="Type to link a new tag">
+					</p>
 				</div>
 				<script type="text/javascript" src="', BASEURL, '/js/ajax.js"></script>
 				<script type="text/javascript" src="', BASEURL, '/js/autosuggest.js"></script>
@@ -240,7 +248,7 @@ class PhotoPage extends Template
 	public function addUserActions()
 	{
 		echo '
-				<div id=user_actions_box>
+				<div id="user_actions_box" class="content-box">
 					<h3>Actions</h3>
 					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete</a>
 				</div>';
-- 
2.46.0


From 021df2df93a2172389927458567b44240a613424 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 14:12:56 +0100
Subject: [PATCH 09/79] Pagination: use larger page indices on photo and album
 index pages

---
 controllers/ViewPeople.php     | 1 +
 controllers/ViewPhotoAlbum.php | 1 +
 controllers/ViewTimeline.php   | 1 +
 3 files changed, 3 insertions(+)

diff --git a/controllers/ViewPeople.php b/controllers/ViewPeople.php
index 3829327..014ebcb 100644
--- a/controllers/ViewPeople.php
+++ b/controllers/ViewPeople.php
@@ -52,6 +52,7 @@ class ViewPeople extends HTMLController
 			'start' => $start,
 			'base_url' => BASEURL . '/people/',
 			'page_slug' => 'page/%PAGE%/',
+			'index_class' => 'pagination-lg justify-content-center',
 		]);
 		$this->page->adopt(new PageIndexWidget($pagination));
 
diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php
index 73c84e2..aa3d632 100644
--- a/controllers/ViewPhotoAlbum.php
+++ b/controllers/ViewPhotoAlbum.php
@@ -111,6 +111,7 @@ class ViewPhotoAlbum extends HTMLController
 				'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE,
 				'base_url' => BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : ''),
 				'page_slug' => 'page/%PAGE%/',
+				'index_class' => 'pagination-lg justify-content-center',
 			]);
 			$this->page->adopt(new PageIndexWidget($index));
 		}
diff --git a/controllers/ViewTimeline.php b/controllers/ViewTimeline.php
index bf9d56e..72dfd76 100644
--- a/controllers/ViewTimeline.php
+++ b/controllers/ViewTimeline.php
@@ -46,6 +46,7 @@ class ViewTimeline extends HTMLController
 				'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE,
 				'base_url' => BASEURL . '/timeline/',
 				'page_slug' => 'page/%PAGE%/',
+				'index_class' => 'pagination-lg justify-content-center',
 			]);
 			$this->page->adopt(new PageIndexWidget($index));
 		}
-- 
2.46.0


From 812c7a4f205c6fbb6f7b8eb38af022809d47028d Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 14:13:29 +0100
Subject: [PATCH 10/79] PhotoPage: change previous/next icons

---
 public/css/default.css  | 10 ++++------
 templates/PhotoPage.php |  8 ++++----
 2 files changed, 8 insertions(+), 10 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 541f3e6..e88e043 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -401,17 +401,15 @@ a#previous_photo:hover, a#next_photo:hover {
 	color: #000;
 }
 #previous_photo {
+	border-top-right-radius: 0.5rem;
+	border-bottom-right-radius: 0.5rem;
 	left: 0;
 }
-#previous_photo:before {
-	content: '←';
-}
 #next_photo {
+	border-top-left-radius: 0.5rem;
+	border-bottom-left-radius: 0.5rem;
 	right: 0;
 }
-#next_photo:before {
-	content: '→';
-}
 
 #sub_photo h2, #sub_photo h3, #photo_exif_box h3, #user_actions_box h3 {
 	font: 600 20px/30px "Open Sans", sans-serif;
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index 1c0bd5c..772a4f9 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -84,17 +84,17 @@ class PhotoPage extends Template
 	{
 		if ($this->previous_photo_url)
 			echo '
-				<a href="', $this->previous_photo_url, '" id="previous_photo"><em>Previous photo</em></a>';
+				<a href="', $this->previous_photo_url, '" id="previous_photo"><i class="bi bi-arrow-left"></i></a>';
 		else
 			echo '
-				<span id="previous_photo"><em>Previous photo</em></span>';
+				<span id="previous_photo"><i class="bi bi-arrow-left"></i></span>';
 
 		if ($this->next_photo_url)
 			echo '
-				<a href="', $this->next_photo_url, '" id="next_photo"><em>Next photo</em></a>';
+				<a href="', $this->next_photo_url, '" id="next_photo"><i class="bi bi-arrow-right"></i></a>';
 		else
 			echo '
-				<span id="next_photo"><em>Next photo</em></span>';
+				<span id="next_photo"><i class="bi bi-arrow-right"></i></span>';
 	}
 
 	private function photoMeta()
-- 
2.46.0


From b9bd2bf4994e3f12d7d54bd70b2e82ee708ca26c Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 14:17:38 +0100
Subject: [PATCH 11/79] AlbumHeaderBox: apply some border radius to tag headers

---
 public/css/default.css       | 7 ++++++-
 templates/AlbumHeaderBox.php | 4 +++-
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index e88e043..450d770 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -107,6 +107,7 @@ ul#nav li a:hover {
 .tiled_header {
 	background: #fff;
 	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	border-radius: 0.5rem;
 	color: #000;
 	clear: both;
 	float: left;
@@ -248,15 +249,19 @@ ul#nav li a:hover {
 .album_title_box > a {
 	background: #fff;
 	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	border-top-left-radius: 0.5rem;
+	border-bottom-left-radius: 0.5rem;
 	display: inline-block;
 	float: left;
 	font-size: 2em;
 	line-height: 1;
-	padding: 8px 10px 14px;
+	padding: 8px 10px;
 }
 .album_title_box > div {
 	background: #fff;
 	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	border-top-right-radius: 0.5rem;
+	border-bottom-right-radius: 0.5rem;
 	float: left;
 	font: inherit;
 	padding: 6px 22px;
diff --git a/templates/AlbumHeaderBox.php b/templates/AlbumHeaderBox.php
index 7dfccf1..1d82818 100644
--- a/templates/AlbumHeaderBox.php
+++ b/templates/AlbumHeaderBox.php
@@ -25,7 +25,9 @@ class AlbumHeaderBox extends Template
 	{
 		echo '
 			<div class="album_title_box">
-				<a class="back_button" href="', $this->back_link, '" title="', $this->back_link_title, '">&larr;</a>
+				<a class="back_button" href="', $this->back_link, '" title="', $this->back_link_title, '">
+					<i class="bi bi-arrow-left"></i>
+				</a>
 				<div>
 					<h2>', $this->title, '</h2>';
 
-- 
2.46.0


From a6fd8d27644894f3ecc3750f0c3544ebab1d2f83 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 14:24:17 +0100
Subject: [PATCH 12/79] Admin controllers: apply new column classes

---
 controllers/ManageAlbums.php | 4 ++--
 controllers/ManageAssets.php | 1 -
 controllers/ManageErrors.php | 5 +++--
 controllers/ManageTags.php   | 4 ++--
 controllers/ManageUsers.php  | 4 ++--
 5 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/controllers/ManageAlbums.php b/controllers/ManageAlbums.php
index f989e6a..6f578b1 100644
--- a/controllers/ManageAlbums.php
+++ b/controllers/ManageAlbums.php
@@ -18,7 +18,7 @@ class ManageAlbums extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/editalbum/',
 				'method' => 'get',
-				'class' => 'float-end',
+				'class' => 'col-md-6 text-end',
 				'buttons' => [
 					'add' => [
 						'type' => 'submit',
@@ -60,7 +60,7 @@ class ManageAlbums extends HTMLController
 			'title' => 'Manage albums',
 			'no_items_label' => 'No albums meet the requirements of the current filter.',
 			'items_per_page' => 9999,
-			'index_class' => 'float-start',
+			'index_class' => 'col-md-6',
 			'base_url' => BASEURL . '/managealbums/',
 			'get_data' => function($offset = 0, $limit = 9999, $order = '', $direction = 'up') {
 				if (!in_array($order, ['id_tag', 'tag', 'slug', 'count']))
diff --git a/controllers/ManageAssets.php b/controllers/ManageAssets.php
index 1afc85b..463ede5 100644
--- a/controllers/ManageAssets.php
+++ b/controllers/ManageAssets.php
@@ -67,7 +67,6 @@ class ManageAssets extends HTMLController
 			'title' => 'Manage assets',
 			'no_items_label' => 'No assets meet the requirements of the current filter.',
 			'items_per_page' => 30,
-			'index_class' => 'pull_left',
 			'base_url' => BASEURL . '/manageassets/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
 				if (!in_array($order, ['id_asset', 'title', 'subdir', 'filename']))
diff --git a/controllers/ManageErrors.php b/controllers/ManageErrors.php
index 9619753..f510c7b 100644
--- a/controllers/ManageErrors.php
+++ b/controllers/ManageErrors.php
@@ -29,11 +29,12 @@ class ManageErrors extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/manageerrors/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
 				'method' => 'post',
-				'class' => 'float-end',
+				'class' => 'col-md-6 text-end',
 				'buttons' => [
 					'flush' => [
 						'type' => 'submit',
 						'caption' => 'Delete all',
+						'class' => 'btn-danger',
 					],
 				],
 			],
@@ -99,7 +100,7 @@ class ManageErrors extends HTMLController
 			'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '',
 			'no_items_label' => "No errors to display -- we're all good!",
 			'items_per_page' => 20,
-			'index_class' => 'float-start',
+			'index_class' => 'col-md-6',
 			'base_url' => BASEURL . '/manageerrors/',
 			'get_count' => 'ErrorLog::getCount',
 			'get_data' => function($offset = 0, $limit = 20, $order = '', $direction = 'down') {
diff --git a/controllers/ManageTags.php b/controllers/ManageTags.php
index a49f9aa..ff49780 100644
--- a/controllers/ManageTags.php
+++ b/controllers/ManageTags.php
@@ -18,7 +18,7 @@ class ManageTags extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/edittag/',
 				'method' => 'get',
-				'class' => 'float-end',
+				'class' => 'col-md-6 text-end',
 				'buttons' => [
 					'add' => [
 						'type' => 'submit',
@@ -65,7 +65,7 @@ class ManageTags extends HTMLController
 			'title' => 'Manage tags',
 			'no_items_label' => 'No tags meet the requirements of the current filter.',
 			'items_per_page' => 30,
-			'index_class' => 'float-start',
+			'index_class' => 'col-md-6',
 			'base_url' => BASEURL . '/managetags/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'up') {
 				if (!in_array($order, ['id_tag', 'tag', 'slug', 'kind', 'count']))
diff --git a/controllers/ManageUsers.php b/controllers/ManageUsers.php
index 14ef97f..cf53e24 100644
--- a/controllers/ManageUsers.php
+++ b/controllers/ManageUsers.php
@@ -18,7 +18,7 @@ class ManageUsers extends HTMLController
 			'form' => [
 				'action' => BASEURL . '/edituser/',
 				'method' => 'get',
-				'class' => 'float-end',
+				'class' => 'col-md-6 text-end',
 				'buttons' => [
 					'add' => [
 						'type' => 'submit',
@@ -94,7 +94,7 @@ class ManageUsers extends HTMLController
 			'title' => 'Manage users',
 			'no_items_label' => 'No users meet the requirements of the current filter.',
 			'items_per_page' => 30,
-			'index_class' => 'float-start',
+			'index_class' => 'col-md-6',
 			'base_url' => BASEURL . '/manageusers/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
 				if (!in_array($order, ['id_user', 'surname', 'first_name', 'slug', 'emailaddress', 'last_action_time', 'ip_address', 'is_admin']))
-- 
2.46.0


From 5bb8c020bd16a80f4815c6ac8e60ac989b871065 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 14:31:44 +0100
Subject: [PATCH 13/79] EditAssetForm: replace widget class with generic
 content box

---
 public/css/admin.css        | 26 +-------------------------
 public/js/crop_editor.js    | 10 +++++++---
 templates/DummyBox.php      |  6 +++---
 templates/EditAssetForm.php | 30 ++++++++++++++++--------------
 4 files changed, 27 insertions(+), 45 deletions(-)

diff --git a/public/css/admin.css b/public/css/admin.css
index 93007cc..8dd510d 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -74,30 +74,6 @@ body {
 }
 
 
-/* Admin widgets
-------------------*/
-.widget {
-	background: #fff;
-	padding: 25px;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-}
-.widget h3 {
-	margin: 0 0 1em;
-	font: 400 18px "Raleway", sans-serif;
-}
-.widget p, .errormsg p {
-	margin: 0;
-}
-.widget ul {
-	margin: 0;
-	list-style: none;
-	padding: 0;
-}
-.widget li {
-	line-height: 1.7em;
-}
-
-
 /* Edit icon on tiled grids
 -----------------------------*/
 .tiled_grid div.landscape, .tiled_grid div.portrait, .tiled_grid div.panorama {
@@ -134,7 +110,7 @@ body {
 	color: #fff;
 }
 #crop_editor input[type=number] {
-	width: 50px;
+	width: 75px;
 	background: #555;
 	color: #fff;
 }
diff --git a/public/js/crop_editor.js b/public/js/crop_editor.js
index 555ec83..286f1da 100644
--- a/public/js/crop_editor.js
+++ b/public/js/crop_editor.js
@@ -3,7 +3,7 @@ class CropEditor {
 		this.opt = opt;
 
 		this.edit_crop_button = document.createElement("span");
-		this.edit_crop_button.className = "btn";
+		this.edit_crop_button.className = "btn btn-light";
 		this.edit_crop_button.textContent = "Edit crop";
 		this.edit_crop_button.addEventListener('click', this.show.bind(this));
 
@@ -34,6 +34,7 @@ class CropEditor {
 		this.position.appendChild(source_x_label);
 
 		this.source_x = document.createElement("input");
+		this.source_x.className = 'form-control d-inline';
 		this.source_x.type = 'number';
 		this.source_x.addEventListener("change", this.positionBoundary.bind(this));
 		this.source_x.addEventListener("keyup", this.positionBoundary.bind(this));
@@ -43,6 +44,7 @@ class CropEditor {
 		this.position.appendChild(source_y_label);
 
 		this.source_y = document.createElement("input");
+		this.source_y.className = 'form-control d-inline';
 		this.source_y.type = 'number';
 		this.source_y.addEventListener("change", this.positionBoundary.bind(this));
 		this.source_y.addEventListener("keyup", this.positionBoundary.bind(this));
@@ -52,6 +54,7 @@ class CropEditor {
 		this.position.appendChild(crop_width_label);
 
 		this.crop_width = document.createElement("input");
+		this.crop_width.className = 'form-control d-inline';
 		this.crop_width.type = 'number';
 		this.crop_width.addEventListener("change", this.positionBoundary.bind(this));
 		this.crop_width.addEventListener("keyup", this.positionBoundary.bind(this));
@@ -61,6 +64,7 @@ class CropEditor {
 		this.position.appendChild(crop_height_label);
 
 		this.crop_height = document.createElement("input");
+		this.crop_height.className = 'form-control d-inline';
 		this.crop_height.type = 'number';
 		this.crop_height.addEventListener("change", this.positionBoundary.bind(this));
 		this.crop_height.addEventListener("keyup", this.positionBoundary.bind(this));
@@ -78,13 +82,13 @@ class CropEditor {
 		this.crop_constrain_label.appendChild(this.crop_constrain_text);
 
 		this.save_button = document.createElement("span");
-		this.save_button.className = "btn";
+		this.save_button.className = "btn btn-light";
 		this.save_button.textContent = "Save";
 		this.save_button.addEventListener('click', this.save.bind(this));
 		this.position.appendChild(this.save_button);
 
 		this.abort_button = document.createElement("span");
-		this.abort_button.className = "btn btn-red";
+		this.abort_button.className = "btn btn-danger";
 		this.abort_button.textContent = "Abort";
 		this.abort_button.addEventListener('click', this.hide.bind(this));
 		this.position.appendChild(this.abort_button);
diff --git a/templates/DummyBox.php b/templates/DummyBox.php
index 2632384..23dc086 100644
--- a/templates/DummyBox.php
+++ b/templates/DummyBox.php
@@ -8,9 +8,9 @@
 
 class DummyBox extends SubTemplate
 {
-	private $_class;
-	private $_content;
-	private $_title;
+	protected $_class;
+	protected $_content;
+	protected $_title;
 
 	public function __construct($title = '', $content = '', $class = '')
 	{
diff --git a/templates/EditAssetForm.php b/templates/EditAssetForm.php
index 490b0d2..8bdcdaf 100644
--- a/templates/EditAssetForm.php
+++ b/templates/EditAssetForm.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2015, Aaron van Geffen
  *****************************************************************************/
 
-class EditAssetForm extends SubTemplate
+class EditAssetForm extends Template
 {
 	private $asset;
 	private $thumbs;
@@ -17,14 +17,14 @@ class EditAssetForm extends SubTemplate
 		$this->thumbs = $thumbs;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 			<form id="asset_form" action="" method="post" enctype="multipart/form-data">
-				<div class="boxed_content" style="margin-bottom: 2%">
+				<div class="content-box">
 					<div style="float: right">
-						<a class="btn btn-red" href="', BASEURL, '/', $this->asset->getSlug(), '?delete_confirmed">Delete asset</a>
-						<input type="submit" value="Save asset data">
+						<a class="btn btn-danger" href="', BASEURL, '/', $this->asset->getSlug(), '?delete_confirmed">Delete asset</a>
+						<input class="btn btn-primary" type="submit" value="Save asset data">
 					</div>
 					<h2>Edit asset \'', $this->asset->getTitle(), '\' (', $this->asset->getFilename(), ')</h2>
 				</div>';
@@ -32,14 +32,15 @@ class EditAssetForm extends SubTemplate
 		$this->section_replace();
 
 		echo '
-				<div style="float: left; width: 60%; margin-right: 2%">';
+				<div class="row">
+					<div class="col-md-8">';
 
 		$this->section_key_info();
 		$this->section_asset_meta();
 
 		echo '
-				</div>
-				<div style="float: left; width: 38%;">';
+					</div>
+					<div class="col-md-4">';
 
 		if (!empty($this->thumbs))
 			$this->section_thumbnails();
@@ -47,11 +48,12 @@ class EditAssetForm extends SubTemplate
 		$this->section_linked_tags();
 
 		echo '
-				</div>';
+					</div>';
 
 		$this->section_crop_editor();
 
 		echo '
+				</div>
 			</form>';
 	}
 
@@ -59,7 +61,7 @@ class EditAssetForm extends SubTemplate
 	{
 		$date_captured = $this->asset->getDateCaptured();
 		echo '
-				<div class="widget key_info">
+				<div class="content-box key_info">
 					<h3>Key info</h3>
 					<dl>
 						<dt>Title</dt>
@@ -81,7 +83,7 @@ class EditAssetForm extends SubTemplate
 	protected function section_linked_tags()
 	{
 		echo '
-				<div class="widget linked_tags" style="margin-top: 2%">
+				<div class="content-box linked_tags">
 					<h3>Linked tags</h3>
 					<ul id="tag_list">';
 
@@ -134,7 +136,7 @@ class EditAssetForm extends SubTemplate
 	protected function section_thumbnails()
 	{
 		echo '
-				<div class="widget linked_thumbs">
+				<div class="content-box linked_thumbs">
 					<h3>Thumbnails</h3>
 					View: <select id="thumbnail_src">';
 
@@ -218,7 +220,7 @@ class EditAssetForm extends SubTemplate
 	protected function section_asset_meta()
 	{
 		echo '
-				<div class="widget asset_meta" style="margin-top: 2%">
+				<div class="content-box asset_meta" style="margin-top: 2%">
 					<h3>Asset meta data</h3>
 					<ul>';
 
@@ -246,7 +248,7 @@ class EditAssetForm extends SubTemplate
 	protected function section_replace()
 	{
 		echo '
-				<div class="widget replace_asset" style="margin-bottom: 2%; display: block">
+				<div class="content-box replace_asset" style="margin-bottom: 2%; display: block">
 					<h3>Replace asset</h3>
 					File: <input type="file" name="replacement">
 					Target: <select name="replacement_target">
-- 
2.46.0


From b1378a3b595267a5a7291430dc9426d5340ab5ea Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 14:38:49 +0100
Subject: [PATCH 14/79] DummyBox: fix SubTemplate inheritance

---
 templates/DummyBox.php | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

diff --git a/templates/DummyBox.php b/templates/DummyBox.php
index 23dc086..0ef5f24 100644
--- a/templates/DummyBox.php
+++ b/templates/DummyBox.php
@@ -8,28 +8,26 @@
 
 class DummyBox extends SubTemplate
 {
-	protected $_class;
 	protected $_content;
-	protected $_title;
 
-	public function __construct($title = '', $content = '', $class = '')
+	public function __construct($title = '', $content = '', $class = null)
 	{
-		$this->_title = $title;
+		parent::__construct($title);
 		$this->_content = $content;
-		$this->_class = $class;
+
+		if (isset($class))
+			$this->_class .= $class;
 	}
 
 	protected function html_content()
 	{
-		echo '
-			<div class="boxed_content', $this->_class ? ' ' . $this->_class : '', '">', $this->_title ? '
-				<h2>' . $this->_title . '</h2>' : '', '
-				', $this->_content;
+		if ($this->_title)
+			echo '
+				<h2>', $this->_title, '</h2>';
+
+		echo $this->_content;
 
 		foreach ($this->_subtemplates as $template)
 			$template->html_main();
-
-		echo '
-			</div>';
 	}
 }
-- 
2.46.0


From 277611e0ac6c237bdc6abcafe3afd696bb993722 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 15:14:05 +0100
Subject: [PATCH 15/79] Introduce new menu classes and navigation templates

---
 controllers/HTMLController.php |  2 --
 models/AdminMenu.php           | 58 +++++++++++++++++++++++++++++++
 models/Dispatcher.php          |  1 -
 models/ErrorHandler.php        |  1 -
 models/MainMenu.php            | 41 ++++++++++++++++++++++
 models/Menu.php                | 18 ++++++++++
 models/UserMenu.php            | 60 ++++++++++++++++++++++++++++++++
 public/css/admin.css           | 38 ---------------------
 public/css/default.css         | 31 +++--------------
 templates/AdminBar.php         | 38 ---------------------
 templates/MainNavBar.php       | 51 ++++++++++++++++++++++++++++
 templates/MainTemplate.php     | 16 ++++-----
 templates/NavBar.php           | 62 ++++++++++++++++++++++++++++++++++
 13 files changed, 302 insertions(+), 115 deletions(-)
 create mode 100644 models/AdminMenu.php
 create mode 100644 models/MainMenu.php
 create mode 100644 models/Menu.php
 create mode 100644 models/UserMenu.php
 delete mode 100644 templates/AdminBar.php
 create mode 100644 templates/MainNavBar.php
 create mode 100644 templates/NavBar.php

diff --git a/controllers/HTMLController.php b/controllers/HTMLController.php
index d03bc50..a5d5236 100644
--- a/controllers/HTMLController.php
+++ b/controllers/HTMLController.php
@@ -22,8 +22,6 @@ abstract class HTMLController
 		if (Registry::get('user')->isAdmin())
 		{
 			$this->page->appendStylesheet(BASEURL . '/css/admin.css');
-			$this->admin_bar = new AdminBar();
-			$this->page->adopt($this->admin_bar);
 		}
 	}
 
diff --git a/models/AdminMenu.php b/models/AdminMenu.php
new file mode 100644
index 0000000..aae9e0d
--- /dev/null
+++ b/models/AdminMenu.php
@@ -0,0 +1,58 @@
+<?php
+/*****************************************************************************
+ * AdminMenu.php
+ * Contains the admin navigation logic.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+class AdminMenu extends Menu
+{
+	public function __construct()
+	{
+		$user = Registry::has('user') ? Registry::get('user') : new Guest();
+		if (!$user->isAdmin())
+			return;
+
+		$this->items[] = [
+			'label' => 'Admin',
+			'icon' => 'gear',
+			'subs' => [
+				[
+					'uri' => '/managealbums/',
+					'label' => 'Albums',
+				],
+				[
+					'uri' => '/manageassets/',
+					'label' => 'Assets',
+				],
+				[
+					'uri' => '/managetags/',
+					'label' => 'Tags',
+				],
+				[
+					'uri' => '/manageusers/',
+					'label' => 'Users',
+				],
+				[
+					'uri' => '/manageerrors/',
+					'label' => 'Errors',
+					'badge' => ErrorLog::getCount(),
+				],
+			],
+		];
+
+		foreach ($this->items as $i => $item)
+		{
+			if (isset($item['uri']))
+				$this->items[$i]['url'] = BASEURL . $item['uri'];
+
+			if (!isset($item['subs']))
+				continue;
+
+			foreach ($item['subs'] as $j => $subitem)
+				$this->items[$i]['subs'][$j]['url'] = BASEURL . $subitem['uri'];
+		}
+	}
+}
diff --git a/models/Dispatcher.php b/models/Dispatcher.php
index 8280797..3a6b529 100644
--- a/models/Dispatcher.php
+++ b/models/Dispatcher.php
@@ -86,7 +86,6 @@ class Dispatcher
 		if (Registry::has('user') && Registry::get('user')->isAdmin())
 		{
 			$page->appendStylesheet(BASEURL . '/css/admin.css');
-			$page->adopt(new AdminBar());
 		}
 
 		$page->adopt(new DummyBox('Well, this is a bit embarrassing!', '<p>The page you requested could not be found. Don\'t worry, it\'s probably not your fault. You\'re welcome to browse the website, though!</p>', 'errormsg'));
diff --git a/models/ErrorHandler.php b/models/ErrorHandler.php
index da650e9..3139444 100644
--- a/models/ErrorHandler.php
+++ b/models/ErrorHandler.php
@@ -168,7 +168,6 @@ class ErrorHandler
 			if ($is_admin)
 			{
 				$page->appendStylesheet(BASEURL . '/css/admin.css');
-				$page->adopt(new AdminBar());
 			}
 		}
 		elseif (!$is_sensitive)
diff --git a/models/MainMenu.php b/models/MainMenu.php
new file mode 100644
index 0000000..e1ccb05
--- /dev/null
+++ b/models/MainMenu.php
@@ -0,0 +1,41 @@
+<?php
+/*****************************************************************************
+ * MainMenu.php
+ * Contains the main navigation logic.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+class MainMenu extends Menu
+{
+	public function __construct()
+	{
+		$this->items = [
+			[
+				'uri' => '/',
+				'label' => 'Albums',
+			],
+			[
+				'uri' => '/people/',
+				'label' => 'People',
+			],
+			[
+				'uri' => '/timeline/',
+				'label' => 'Timeline',
+			],
+		];
+
+		foreach ($this->items as $i => $item)
+		{
+			if (isset($item['uri']))
+				$this->items[$i]['url'] = BASEURL . $item['uri'];
+
+			if (!isset($item['subs']))
+				continue;
+
+			foreach ($item['subs'] as $j => $subitem)
+				$this->items[$i]['subs'][$j]['url'] = BASEURL . $subitem['uri'];
+		}
+	}
+}
diff --git a/models/Menu.php b/models/Menu.php
new file mode 100644
index 0000000..cde4de0
--- /dev/null
+++ b/models/Menu.php
@@ -0,0 +1,18 @@
+<?php
+/*****************************************************************************
+ * Menu.php
+ * Contains all navigational menus.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+abstract class Menu
+{
+	protected $items = [];
+
+	public function getItems()
+	{
+		return $this->items;
+	}
+}
diff --git a/models/UserMenu.php b/models/UserMenu.php
new file mode 100644
index 0000000..0204322
--- /dev/null
+++ b/models/UserMenu.php
@@ -0,0 +1,60 @@
+<?php
+/*****************************************************************************
+ * UserMenu.php
+ * Contains the user navigation logic.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+class UserMenu extends Menu
+{
+	public function __construct()
+	{
+		$user = Registry::has('user') ? Registry::get('user') : new Guest();
+		if ($user->isLoggedIn())
+		{
+			$this->items[] = [
+				'label' => $user->getFirstName(),
+				'icon' => 'person-circle',
+				'subs' => [
+
+					[
+						'label' => 'Settings',
+						'uri' => '/accountsettings/',
+					],
+					[
+						'label' => 'Log out',
+						'uri' => '/logout/',
+					],
+				],
+			];
+		}
+		else
+		{
+			$this->items[] = [
+				'label' => 'Log in',
+				'icon' => 'person-circle',
+				'uri' => '/login/',
+			];
+		}
+
+		$this->items[] = [
+			'label' => 'Home',
+			'icon' => 'house-door',
+			'uri' => '/',
+		];
+
+		foreach ($this->items as $i => $item)
+		{
+			if (isset($item['uri']))
+				$this->items[$i]['url'] = BASEURL . $item['uri'];
+
+			if (!isset($item['subs']))
+				continue;
+
+			foreach ($item['subs'] as $j => $subitem)
+				$this->items[$i]['subs'][$j]['url'] = BASEURL . $subitem['uri'];
+		}
+	}
+}
diff --git a/public/css/admin.css b/public/css/admin.css
index 8dd510d..0316b80 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -11,44 +11,6 @@
 	margin: 0 0 0.2em;
 }
 
-/* Admin bar styles
----------------------*/
-body {
-	padding-top: 30px;
-}
-#admin_bar {
-	background: #333;
-	color: #ccc;
-	left: 0;
-	position: fixed;
-	top: 0;
-	width: 100%;
-	z-index: 100;
-}
-#admin_bar ul {
-	list-style: none;
-	margin: 0 auto;
-	max-width: 1280px;
-	min-width: 900px;
-	padding: 2px;
-	width: 95%;
-}
-#admin_bar ul > li {
-	display: inline;
-	border-right: 1px solid #aaa;
-}
-#admin_bar ul > li:last-child {
-	border-right: none;
-}
-#admin_bar li > a {
-	color: inherit;
-	display: inline-block;
-	padding: 4px 6px;
-}
-#admin_bar li a:hover {
-	text-decoration: underline;
-}
-
 
 /* (Tag) autosuggest
 ----------------------*/
diff --git a/public/css/default.css b/public/css/default.css
index 450d770..8cd0846 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -19,17 +19,13 @@ body {
 	background: #aaa 0 -50% fixed;
 }
 
-#wrapper, header {
+#wrapper, header .container {
 	width: 95%;
 	min-width: 900px;
 	max-width: 1280px;
 	margin: 0 auto;
 }
 
-header {
-	overflow: auto;
-}
-
 a {
 	color: #963626;
 	text-decoration: none;
@@ -67,27 +63,10 @@ a:hover h1#logo, a:hover h1#logo:before {
 
 /* Navigation
 ---------------*/
-ul#nav {
-	margin: 55px 10px 0 0;
-	padding: 0;
-	float: right;
-	list-style: none;
-}
-ul#nav li {
-	float: left;
-}
-ul#nav li a {
-	color: #fff;
-	display: block;
-	float: left;
-	font: 200 20px 'Press Start 2P', sans-serif;
-	margin: 0 0 0 32px;
-	padding: 10px 0;
-	text-decoration: none;
-	text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
-}
-ul#nav li a:hover {
-	text-shadow: 2px 2px 3px rgba(0, 0, 0, 0.6);
+.nav-divider {
+	height: 2.5rem;
+	border-left: .1rem solid rgba(255,255,255, 0.2);
+	margin: 0 0.5rem;
 }
 
 
diff --git a/templates/AdminBar.php b/templates/AdminBar.php
deleted file mode 100644
index daa81a8..0000000
--- a/templates/AdminBar.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-/*****************************************************************************
- * AdminBar.php
- * Defines the AdminBar class.
- *
- * Kabuki CMS (C) 2013-2015, Aaron van Geffen
- *****************************************************************************/
-
-class AdminBar extends Template
-{
-	private $extra_items = [];
-
-	public function html_main()
-	{
-		echo '
-			<div id="admin_bar">
-				<ul>
-					<li><a href="', BASEURL, '/managealbums/">Albums</a></li>
-					<li><a href="', BASEURL, '/manageassets/">Assets</a></li>
-					<li><a href="', BASEURL, '/managetags/">Tags</a></li>
-					<li><a href="', BASEURL, '/manageusers/">Users</a></li>
-					<li><a href="', BASEURL, '/manageerrors/">Errors [', ErrorLog::getCount(), ']</a></li>';
-
-		foreach ($this->extra_items as $item)
-			echo '
-					<li><a href="', $item[0], '">', $item[1], '</a></li>';
-
-		echo '
-					<li><a href="', BASEURL, '/logout/">Log out [', Registry::get('user')->getFullName(), ']</a></li>
-				</ul>
-			</div>';
-	}
-
-	public function appendItem($url, $caption)
-	{
-		$this->extra_items[] = [$url, $caption];
-	}
-}
diff --git a/templates/MainNavBar.php b/templates/MainNavBar.php
new file mode 100644
index 0000000..ded5fbb
--- /dev/null
+++ b/templates/MainNavBar.php
@@ -0,0 +1,51 @@
+<?php
+/*****************************************************************************
+ * MainNavBar.php
+ * Contains the primary navigational menu template.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+class MainNavBar extends NavBar
+{
+	protected $outerMenuId = 'mainNav';
+	protected $innerMenuId = 'mainNavigation';
+	protected $ariaLabel = 'Main navigation';
+	protected $navBarClasses = 'navbar-dark bg-dark sticky-top mb-4';
+	protected $primaryBadgeClasses = 'bg-light text-dark';
+	protected $secondaryBadgeClasses = 'bg-dark text-light';
+
+	public function html_main()
+	{
+		echo '
+		<nav id="', $this->outerMenuId, '" class="navbar navbar-expand-lg ', $this->navBarClasses, '" aria-label="', $this->ariaLabel, '">
+			<div class="container">
+				<a class="navbar-brand flex-grow-1" href="', BASEURL, '/">
+					HashRU Pics
+				</a>
+				<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#', $this->innerMenuId, '" aria-controls="', $this->innerMenuId, '" aria-expanded="false" aria-label="Toggle navigation">
+					<span class="navbar-toggler-icon"></span>
+				</button>
+				<div class="collapse navbar-collapse justify-content-end" id="', $this->innerMenuId, '">
+					<ul class="navbar-nav mb-2 mb-lg-0">';
+
+		$mainMenu = new MainMenu();
+		$this->renderMenuItems($mainMenu->getItems());
+
+		echo '
+						<li class="nav-divider d-none d-lg-inline"></li>';
+
+		$adminMenu = new AdminMenu();
+		$this->renderMenuItems($adminMenu->getItems());
+
+		$userMenu = new UserMenu();
+		$this->renderMenuItems($userMenu->getItems());
+
+		echo '
+					</ul>
+				</div>
+			</div>
+		</nav>';
+	}
+}
diff --git a/templates/MainTemplate.php b/templates/MainTemplate.php
index b5224fb..d23df69 100644
--- a/templates/MainTemplate.php
+++ b/templates/MainTemplate.php
@@ -43,15 +43,12 @@ class MainTemplate extends Template
 		, $this->header_html, '
 	</head>
 	<body', !empty($this->classes) ? ' class="' . implode(' ', $this->classes) . '"' : '', '>
-		<header>
-			<a href="', BASEURL, '/">
-				<h1 id="logo">#pics</h1>
-			</a>
-			<ul id="nav">
-				<li><a href="', BASEURL, '/">albums</a></li>
-				<li><a href="', BASEURL, '/people/">people</a></li>
-				<li><a href="', BASEURL, '/timeline/">timeline</a></li>
-			</ul>
+		<header>';
+
+		$bar = new MainNavBar();
+		$bar->html_main();
+
+		echo '
 		</header>
 		<div id="wrapper">';
 
@@ -88,6 +85,7 @@ class MainTemplate extends Template
 				echo '<pre>', strtr($query, "\t", " "), '</pre>';
 
 		echo '
+		<script type="text/javascript" src="', BASEURL, '/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
 	</body>
 </html>';
 	}
diff --git a/templates/NavBar.php b/templates/NavBar.php
new file mode 100644
index 0000000..c625c60
--- /dev/null
+++ b/templates/NavBar.php
@@ -0,0 +1,62 @@
+<?php
+/*****************************************************************************
+ * NavBar.php
+ * Contains the navigational menu template.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2022
+ *****************************************************************************/
+
+abstract class NavBar extends Template
+{
+	protected $primaryBadgeClasses = 'bg-dark text-light';
+	protected $secondaryBadgeClasses = 'bg-light text-dark';
+
+	public function renderMenu(array $items, $navBarClasses = '')
+	{
+		echo '
+					<ul class="navbar-nav ', $navBarClasses, '">';
+
+		$this->renderMenuItems($items, $navBarClasses);
+
+		echo '
+					</ul>';
+	}
+
+	public function renderMenuItems(array $items)
+	{
+		foreach ($items as $menuId => $item)
+		{
+			if (isset($item['icon']))
+				$item['label'] = '<i class="bi bi-' . $item['icon'] . '"></i> ' . $item['label'];
+
+			if (isset($item['badge']))
+				$item['label'] .= ' <span class="badge ' . $this->primaryBadgeClasses . '">' . $item['badge'] . '</span>';
+
+			if (empty($item['subs']))
+			{
+				echo '
+						<li class="nav-item"><a class="nav-link" href="', $item['url'], '">', $item['label'], '</a></li>';
+				continue;
+			}
+
+			echo '
+						<li class="nav-item dropdown">
+							<a class="nav-link dropdown-toggle" href="#" id="menu', $menuId, '" data-bs-toggle="dropdown" aria-expanded="false">', $item['label'], '</a>
+							<ul class="dropdown-menu" aria-labelledby="menu', $menuId, '">';
+
+			foreach ($item['subs'] as $subitem)
+			{
+				if (isset($subitem['badge']))
+					$subitem['label'] .= ' <span class="badge ' . $this->secondaryBadgeClasses . '">' . $subitem['badge'] . '</span>';
+
+				echo '
+								<li><a class="dropdown-item" href="', $subitem['url'], '">', $subitem['label'], '</a></li>';
+			}
+
+			echo '
+							</ul>
+						</li>';
+		}
+	}
+}
-- 
2.46.0


From c6902150f050e042ea1cf091bef3e3d55682ba0e Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 15:17:36 +0100
Subject: [PATCH 16/79] PhotoPage: move edit button from old admin bar to
 widget

---
 controllers/ViewPhoto.php | 4 ----
 templates/PhotoPage.php   | 3 ++-
 2 files changed, 2 insertions(+), 5 deletions(-)

diff --git a/controllers/ViewPhoto.php b/controllers/ViewPhoto.php
index cff5ed6..e46929d 100644
--- a/controllers/ViewPhoto.php
+++ b/controllers/ViewPhoto.php
@@ -27,10 +27,6 @@ class ViewPhoto extends HTMLController
 			$this->handleConfirmDelete($user, $author, $photo);
 		else
 			$this->handleViewPhoto($user, $author, $photo);
-
-		// Add an edit button to the admin bar.
-		if ($user->isAdmin())
-			$this->admin_bar->appendItem(BASEURL . '/editasset/?id=' . $photo->getId(), 'Edit this photo');
 	}
 
 	private function handleConfirmDelete(User $user, User $author, Asset $photo)
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index 772a4f9..490b2cc 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -250,7 +250,8 @@ class PhotoPage extends Template
 		echo '
 				<div id="user_actions_box" class="content-box">
 					<h3>Actions</h3>
-					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete</a>
+					<a class="btn btn-primary" href="', BASEURL, '/editasset/?id=', $this->photo->getId(), '?confirm_delete">Edit photo</a>
+					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete photo</a>
 				</div>';
 	}
 }
-- 
2.46.0


From 87df775c5152518eec0d16ce690bc5c4994b9a5e Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 15:27:15 +0100
Subject: [PATCH 17/79] MainNavBar: re-introduce the space invader

---
 public/css/default.css   | 50 ++++++++++++++++++----------------------
 templates/MainNavBar.php |  3 ++-
 2 files changed, 24 insertions(+), 29 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 8cd0846..4a00c3b 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -4,7 +4,6 @@
  * DO NOT COPY OR RE-USE WITHOUT EXPLICIT WRITTEN PERMISSION. THANK YOU.
  */
 
-@import url(//fonts.googleapis.com/css?family=Press+Start+2P);
 @import url(//fonts.googleapis.com/css?family=Open+Sans:400,400italic,700,700italic);
 
 @font-face {
@@ -17,6 +16,7 @@
 body {
 	font-family: "Open Sans", sans-serif;
 	background: #aaa 0 -50% fixed;
+	padding: 0 0 3rem;
 }
 
 #wrapper, header .container {
@@ -34,40 +34,34 @@ a:hover {
 	color: #262626;
 }
 
-/* Logo
----------*/
-h1#logo {
-	color: #fff;
-	float: left;
-	font: 200 50px 'Press Start 2P', sans-serif;
-	margin: 40px 0 50px 10px;
-	padding: 0;
-	text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.6);
-}
-h1#logo:before {
-	color: #fff;
-	content: 'B';
-	float: left;
-	font: 75px 'Invaders';
-	margin: -4px 20px 0 0;
-	padding: 0;
-	text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.6);
-}
-a h1#logo {
-	text-decoration: none;
-}
-a:hover h1#logo, a:hover h1#logo:before {
-	text-shadow: 2px 2px 3px rgba(0, 0, 0, 0.6);
-}
-
 
 /* Navigation
 ---------------*/
+#mainNav {
+	margin-bottom: 4rem;
+}
 .nav-divider {
 	height: 2.5rem;
 	border-left: .1rem solid rgba(255,255,255, 0.2);
 	margin: 0 0.5rem;
 }
+.navbar-brand {
+	padding-left: 80px;
+	position: relative;
+}
+i.space-invader::before {
+	color: #fff;
+	content: 'B';
+	display: inline-block;
+	font: 85px 'Invaders';
+	height: 85px;
+	left: -25px;
+	position: absolute;
+	text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
+	top: -5px;
+	transform: rotate(-5deg);
+	width: 85px;
+}
 
 
 /* Content boxes
@@ -261,7 +255,7 @@ a:hover h1#logo, a:hover h1#logo:before {
 ---------------------*/
 .album_button_box {
 	float: right;
-	margin-bottom: 20px;
+	margin-bottom: 3rem;
 }
 .album_button_box > a {
 	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
diff --git a/templates/MainNavBar.php b/templates/MainNavBar.php
index ded5fbb..6442673 100644
--- a/templates/MainNavBar.php
+++ b/templates/MainNavBar.php
@@ -12,7 +12,7 @@ class MainNavBar extends NavBar
 	protected $outerMenuId = 'mainNav';
 	protected $innerMenuId = 'mainNavigation';
 	protected $ariaLabel = 'Main navigation';
-	protected $navBarClasses = 'navbar-dark bg-dark sticky-top mb-4';
+	protected $navBarClasses = 'navbar-dark bg-dark sticky-top';
 	protected $primaryBadgeClasses = 'bg-light text-dark';
 	protected $secondaryBadgeClasses = 'bg-dark text-light';
 
@@ -22,6 +22,7 @@ class MainNavBar extends NavBar
 		<nav id="', $this->outerMenuId, '" class="navbar navbar-expand-lg ', $this->navBarClasses, '" aria-label="', $this->ariaLabel, '">
 			<div class="container">
 				<a class="navbar-brand flex-grow-1" href="', BASEURL, '/">
+					<i class="space-invader"></i>
 					HashRU Pics
 				</a>
 				<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#', $this->innerMenuId, '" aria-controls="', $this->innerMenuId, '" aria-expanded="false" aria-label="Toggle navigation">
-- 
2.46.0


From 02b43035f3375d2e222c4a0163ef0ff318500b62 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 15:32:07 +0100
Subject: [PATCH 18/79] AccountSettings: allow users to change their personal
 details

---
 controllers/AccountSettings.php | 130 ++++++++++++++++++++++++++++++++
 models/Member.php               |   5 +-
 models/Router.php               |   1 +
 3 files changed, 135 insertions(+), 1 deletion(-)
 create mode 100644 controllers/AccountSettings.php

diff --git a/controllers/AccountSettings.php b/controllers/AccountSettings.php
new file mode 100644
index 0000000..4bbb056
--- /dev/null
+++ b/controllers/AccountSettings.php
@@ -0,0 +1,130 @@
+<?php
+/*****************************************************************************
+ * AccountSettings.php
+ * Contains the account settings controller.
+ *
+ * Global Data Lab code (C) Radboud University Nijmegen
+ * Programming (C) Aaron van Geffen, 2015-2023
+ *****************************************************************************/
+
+class AccountSettings extends HTMLController
+{
+	public function __construct()
+	{
+		// Not logged in yet?
+		if (!Registry::get('user')->isLoggedIn())
+			throw new NotAllowedException('You need to be logged in to view this page.');
+
+		parent::__construct('Account settings');
+		$form_title = 'Account settings';
+
+		// Session checking!
+		if (empty($_POST))
+			Session::resetSessionToken();
+		else
+			Session::validateSession();
+
+		$fields = [
+			'first_name' => [
+				'type' => 'text',
+				'label' => 'First name',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'surname' => [
+				'type' => 'text',
+				'label' => 'Family name',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'emailaddress' => [
+				'type' => 'text',
+				'label' => 'Email address',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'password1' => [
+				'before_html' => '<div class="offset-sm-2 mt-4"><p>To change your password, please fill out the fields below.</p></div>',
+				'type' => 'password',
+				'label' => 'Password',
+				'size' => 50,
+				'maxlength' => 255,
+				'is_optional' => true,
+			],
+			'password2' => [
+				'type' => 'password',
+				'label' => 'Password (repeat)',
+				'size' => 50,
+				'maxlength' => 255,
+				'is_optional' => true,
+			],
+		];
+
+		$form = new Form([
+			'request_url' => BASEURL . '/' . $_GET['action'] . '/',
+			'fields' => $fields,
+			'submit_caption' => 'Save details',
+		]);
+
+		$user = Registry::get('user');
+
+		// Create the form, add in default values.
+		$form->setData(empty($_POST) ? $user->getProps() : $_POST);
+		$formview = new FormView($form, $form_title);
+		$this->page->adopt($formview);
+
+		// Left a message?
+		if (isset($_SESSION['account_msg']))
+		{
+			$alert = $_SESSION['account_msg'];
+			$formview->adopt(new Alert($alert[0], $alert[1], $alert[2]));
+			unset($_SESSION['account_msg']);
+		}
+
+		// Just updating account settings?
+		if (!empty($_POST))
+		{
+			$form->verify($_POST);
+
+			// Anything missing?
+			if (!empty($form->getMissing()))
+			{
+				$missingFields = array_intersect_key($fields, array_flip($form->getMissing()));
+				$missingFields = array_map(function($field) { return strtolower($field['label']); }, $missingFields);
+				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $missingFields), 'danger'));
+			}
+
+			$data = $form->getData();
+
+			// Just to be on the safe side.
+			$data['first_name'] = htmlspecialchars(trim($data['first_name']));
+			$data['surname'] = htmlspecialchars(trim($data['surname']));
+			$data['emailaddress'] = trim($data['emailaddress']);
+
+			// If it looks like an e-mail address...
+			if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress']))
+				return $formview->adopt(new Alert('Email addresses invalid', 'The email address you entered is not a valid email address.', 'danger'));
+			// Check whether email address is already linked to an account in the database -- just not to the account we happen to be editing, of course.
+			elseif (!empty($data['emailaddress']) && $user->getEmailAddress() !== $data['emailaddress'] && Member::exists($data['emailaddress']))
+				return $formview->adopt(new Alert('Email address already in use', 'Another account is already using this e-mail address.', 'danger'));
+
+			// Changing passwords?
+			if (!empty($data['password1']) && !empty($data['password2']))
+			{
+				if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1']))
+					return $formview->adopt(new Alert('Password not acceptable', 'Please use a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).', 'danger'));
+				elseif ($data['password1'] !== $data['password2'])
+					return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'danger'));
+
+				// Keep just the one.
+				$data['password'] = $data['password1'];
+				unset($data['password1'], $data['password2']);
+				$formview->adopt(new Alert('Your password has been changed', 'Next time you log in, you can use your new password to authenticate yourself.', 'success'));
+			}
+			else
+				$formview->adopt(new Alert('Your account settings have been saved', 'Thank you for keeping your information current.', 'success'));
+
+			$user->update($data);
+		}
+	}
+}
diff --git a/models/Member.php b/models/Member.php
index 9fc21f7..6675dac 100644
--- a/models/Member.php
+++ b/models/Member.php
@@ -110,6 +110,9 @@ class Member extends User
 				$this->is_admin = $value == 1 ? 1 : 0;
 		}
 
+		$params = get_object_vars($this);
+		$params['is_admin'] = $this->is_admin ? 1 : 0;
+
 		return Registry::get('db')->query('
 			UPDATE users
 			SET
@@ -120,7 +123,7 @@ class Member extends User
 				password_hash = {string:password_hash},
 				is_admin = {int:is_admin}
 			WHERE id_user = {int:id_user}',
-			get_object_vars($this));
+			$params);
 	}
 
 	/**
diff --git a/models/Router.php b/models/Router.php
index ba54a61..da37499 100644
--- a/models/Router.php
+++ b/models/Router.php
@@ -11,6 +11,7 @@ class Router
 	public static function route()
 	{
 		$possibleActions = [
+			'accountsettings' => 'AccountSettings',
 			'addalbum' => 'EditAlbum',
 			'albums' => 'ViewPhotoAlbums',
 			'editalbum' => 'EditAlbum',
-- 
2.46.0


From 0a8da104cc0899f6ad5f3cc8c6ef6a9c6aeca1cf Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 16:38:03 +0100
Subject: [PATCH 19/79] MainNavBar: randomize space invader; add Coda font

---
 public/css/default.css   | 41 ++++++++++++++++++++++++++++++++++++++--
 templates/MainNavBar.php |  6 +++++-
 2 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 4a00c3b..2cb4faf 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -5,6 +5,7 @@
  */
 
 @import url(//fonts.googleapis.com/css?family=Open+Sans:400,400italic,700,700italic);
+@import url('//fonts.googleapis.com/css2?family=Coda&display=swap');
 
 @font-face {
 	font-family: 'Invaders';
@@ -21,7 +22,6 @@ body {
 
 #wrapper, header .container {
 	width: 95%;
-	min-width: 900px;
 	max-width: 1280px;
 	margin: 0 auto;
 }
@@ -38,6 +38,7 @@ a:hover {
 /* Navigation
 ---------------*/
 #mainNav {
+	font-family: 'Coda', sans-serif;
 	margin-bottom: 4rem;
 }
 .nav-divider {
@@ -57,10 +58,43 @@ i.space-invader::before {
 	height: 85px;
 	left: -25px;
 	position: absolute;
+	text-align: center;
 	text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
 	top: -5px;
 	transform: rotate(-5deg);
-	width: 85px;
+	width: 110px;
+}
+i.space-invader.alt-1::before {
+	content: 'C';
+}
+i.space-invader.alt-2::before {
+	content: 'D';
+}
+i.space-invader.alt-3::before {
+	content: 'E';
+}
+i.space-invader.alt-4::before {
+	content: 'H';
+}
+i.space-invader.alt-5::before {
+	content: 'I';
+}
+i.space-invader.alt-6::before {
+	content: 'N';
+}
+i.space-invader.alt-7::before {
+	content: 'O';
+}
+@media (max-width: 991px) {
+	.navbar-brand {
+		padding-left: 60px;
+	}
+	i.space-invader::before {
+		font-size: 50px;
+		left: -10px;
+		top: -7px;
+		width: 70px;
+	}
 }
 
 
@@ -73,6 +107,9 @@ i.space-invader::before {
 	border-radius: 0.5rem;
 	box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
 }
+.content-box h1 {
+	font-family: 'Coda', sans-serif;
+}
 
 
 /* Tiled grid
diff --git a/templates/MainNavBar.php b/templates/MainNavBar.php
index 6442673..133c205 100644
--- a/templates/MainNavBar.php
+++ b/templates/MainNavBar.php
@@ -18,11 +18,15 @@ class MainNavBar extends NavBar
 
 	public function html_main()
 	{
+		// Select a random space invader, with a bias towards the mascot
+		$rnd = rand(0, 100);
+		$alt = $rnd > 50 ? ' alt-' . ($rnd % 6 + 1) : '';
+
 		echo '
 		<nav id="', $this->outerMenuId, '" class="navbar navbar-expand-lg ', $this->navBarClasses, '" aria-label="', $this->ariaLabel, '">
 			<div class="container">
 				<a class="navbar-brand flex-grow-1" href="', BASEURL, '/">
-					<i class="space-invader"></i>
+					<i class="space-invader', $alt, '"></i>
 					HashRU Pics
 				</a>
 				<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#', $this->innerMenuId, '" aria-controls="', $this->innerMenuId, '" aria-expanded="false" aria-label="Toggle navigation">
-- 
2.46.0


From febe7bb405468f9ee350a927d11daf14e8c94dbb Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 16:39:30 +0100
Subject: [PATCH 20/79] MainNavBar: hide navigation when not logged in

---
 templates/MainNavBar.php | 27 +++++++++++++++++----------
 1 file changed, 17 insertions(+), 10 deletions(-)

diff --git a/templates/MainNavBar.php b/templates/MainNavBar.php
index 133c205..a83a356 100644
--- a/templates/MainNavBar.php
+++ b/templates/MainNavBar.php
@@ -31,25 +31,32 @@ class MainNavBar extends NavBar
 				</a>
 				<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#', $this->innerMenuId, '" aria-controls="', $this->innerMenuId, '" aria-expanded="false" aria-label="Toggle navigation">
 					<span class="navbar-toggler-icon"></span>
-				</button>
+				</button>';
+
+		if (Registry::get('user')->isLoggedIn())
+		{
+			echo '
 				<div class="collapse navbar-collapse justify-content-end" id="', $this->innerMenuId, '">
 					<ul class="navbar-nav mb-2 mb-lg-0">';
 
-		$mainMenu = new MainMenu();
-		$this->renderMenuItems($mainMenu->getItems());
+			$mainMenu = new MainMenu();
+			$this->renderMenuItems($mainMenu->getItems());
 
-		echo '
+			echo '
 						<li class="nav-divider d-none d-lg-inline"></li>';
 
-		$adminMenu = new AdminMenu();
-		$this->renderMenuItems($adminMenu->getItems());
+			$adminMenu = new AdminMenu();
+			$this->renderMenuItems($adminMenu->getItems());
 
-		$userMenu = new UserMenu();
-		$this->renderMenuItems($userMenu->getItems());
+			$userMenu = new UserMenu();
+			$this->renderMenuItems($userMenu->getItems());
+
+			echo '
+					</ul>
+				</div>';
+		}
 
 		echo '
-					</ul>
-				</div>
 			</div>
 		</nav>';
 	}
-- 
2.46.0


From 556bbb275310ea8e72a5c066867c563a3f02df85 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 16:43:53 +0100
Subject: [PATCH 21/79] Use Coda font for buttons and headers

---
 public/css/default.css | 20 +++++++++++---------
 1 file changed, 11 insertions(+), 9 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 2cb4faf..ecc227b 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -34,6 +34,10 @@ a:hover {
 	color: #262626;
 }
 
+.btn {
+	font-family: 'Coda', 'sans-serif';
+}
+
 
 /* Navigation
 ---------------*/
@@ -107,8 +111,11 @@ i.space-invader.alt-7::before {
 	border-radius: 0.5rem;
 	box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
 }
-.content-box h1 {
+.content-box h1,
+.content-box h2,
+.content-box h3 {
 	font-family: 'Coda', sans-serif;
+	margin-bottom: 0.5em;
 }
 
 
@@ -122,7 +129,7 @@ i.space-invader.alt-7::before {
 	clear: both;
 	float: left;
 	margin: 0 0 1.5% 0;
-	font: 400 18px/2.2 "Open Sans", sans-serif;
+	font: 400 18px/2.2 'Coda', sans-serif;
 	padding: 6px 22px;
 	text-overflow: ellipsis;
 	white-space: nowrap;
@@ -280,7 +287,7 @@ i.space-invader.alt-7::before {
 }
 .album_title_box h2 {
 	color: #262626;
-	font: 400 18px/2 "Open Sans", sans-serif !important;
+	font: 400 18px/2 'Coda', sans-serif !important;
 	margin: 0;
 }
 .album_title_box p {
@@ -427,13 +434,8 @@ a#previous_photo:hover, a#next_photo:hover {
 }
 
 #sub_photo h2, #sub_photo h3, #photo_exif_box h3, #user_actions_box h3 {
-	font: 600 20px/30px "Open Sans", sans-serif;
-	margin: 0 0 10px;
+	margin-bottom: 1rem;
 }
-#sub_photo h3 {
-	font-size: 16px;
-}
-
 #sub_photo #tag_list {
 	list-style: none;
 	margin: 1em 0;
-- 
2.46.0


From 326c8f11ee867dcd89f7de0ba77ab9f2e2783303 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 16:55:22 +0100
Subject: [PATCH 22/79] Change colours for buttons and page indices

---
 public/css/default.css | 23 +++++++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/public/css/default.css b/public/css/default.css
index ecc227b..032ba30 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -34,9 +34,32 @@ a:hover {
 	color: #262626;
 }
 
+.page-link {
+	color: #b50707;
+}
+.page-link:hover {
+	color: #a40d0d;
+}
+.active > .page-link, .page-link.active {
+	background-color: #990b0b;
+	border-color: #a40d0d;
+}
+
 .btn {
 	font-family: 'Coda', 'sans-serif';
 }
+.btn-primary {
+	--bs-btn-bg: #6c757d;
+	--bs-btn-border-color: #6c757d;
+	--bs-btn-hover-bg: #5c636a;
+	--bs-btn-hover-border-color: #565e64;
+	--bs-btn-focus-shadow-rgb: 130, 138, 145;
+	--bs-btn-active-bg: #565e64;
+	--bs-btn-active-border-color: #51585e;
+	--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+	--bs-btn-disabled-bg: #6c757d;
+	--bs-btn-disabled-border-color: #6c757d;
+}
 
 
 /* Navigation
-- 
2.46.0


From 7d19cf823df57df92f3bb12bcd229f1f518429d8 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:04:30 +0100
Subject: [PATCH 23/79] Pass aspect ratio into photo thumbnails

---
 templates/PhotosIndex.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php
index 25255c5..7fbdf55 100644
--- a/templates/PhotosIndex.php
+++ b/templates/PhotosIndex.php
@@ -100,7 +100,7 @@ class PhotosIndex extends Template
 		else
 			echo ' srcset="', $image->getThumbnailUrl($image->width(), $image->height(), true), ' 2x"';
 
-		echo ' alt="" title="', $image->getTitle(), '">';
+		echo ' alt="" title="', $image->getTitle(), '" style="aspect-ratio: ', $image->ratio(), '">';
 
 		if ($this->show_labels)
 			echo '
-- 
2.46.0


From e6f7476037cd1a47750f104ac10fc5d890475a61 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:15:59 +0100
Subject: [PATCH 24/79] MainNavBar: let space invader rotate on hover

---
 public/css/default.css | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/public/css/default.css b/public/css/default.css
index 032ba30..8e85c9c 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -89,8 +89,12 @@ i.space-invader::before {
 	text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
 	top: -5px;
 	transform: rotate(-5deg);
+	transition: 0.25s;
 	width: 110px;
 }
+.navbar-brand:hover i.space-invader::before {
+	transform: rotate(5deg);
+}
 i.space-invader.alt-1::before {
 	content: 'C';
 }
-- 
2.46.0


From 54c4294d08efc2660cddb0051bc1a11c455f1677 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:16:53 +0100
Subject: [PATCH 25/79] Add 'no thumb' vector image for use while loading

---
 public/css/default.css    |   1 +
 public/images/nothumb.png | Bin 3694 -> 0 bytes
 public/images/nothumb.svg |  10 ++++++++++
 templates/AlbumIndex.php  |   5 +++--
 templates/PhotosIndex.php |   2 +-
 5 files changed, 15 insertions(+), 3 deletions(-)
 delete mode 100644 public/images/nothumb.png
 create mode 100644 public/images/nothumb.svg

diff --git a/public/css/default.css b/public/css/default.css
index 8e85c9c..3c60ef9 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -180,6 +180,7 @@ i.space-invader.alt-7::before {
 	width: 31%;
 }
 .tiled_grid div img {
+	background: url('../images/nothumb.svg') center no-repeat;
 	border: none;
 	width: 100%;
 }
diff --git a/public/images/nothumb.png b/public/images/nothumb.png
deleted file mode 100644
index ce6a2f2d8c7db907ef15baf54f579481e35a5e7a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 3694
zcma)93pmqzA766I<>)wBa(NqalE&OG3mt8WG?&6g$7Rhw)@)-NyCAQlbWZE-2oVlm
zbkyt8mP^WQ#n~YfMNK85NEsQqgz)~SQ|Eo2_w79Io;~0F{=eV%^Z8!?zvsK3e7p{*
ztLUgeAP{vAcl2QhWJ4ObZdaBAr3VvM3;t|lxZxN_sK*&hJPm-jhEtCLFb^_50yqrd
z!`U%+0VfD#;|`)9j)B7*LK3KCbG%H)oJEcX(GZB!URE@oKmr)BV?YFv;%qq8ATWdx
z!<`MWwipXcGzy3$x^rm25ssH1fkPrVh8ym6fjO~|AORU*;9)Fs6ormtIUBC?BEhw6
z8(|1rS7DHx4L>@C!}!2ZR2l%YHMce+SXf%Z?Ci`fZEWrAEa5OK3rj161^Bfyv$R3l
z+99p%VSjuKL2I<|<H*D4{eRd3pPUUN8H{Ko0>NZ5&6(EbR9XbW($UdT#$jb;25Okm
z*%StzWk#VJePTcZbOMbS%^*@KFc~BM7&VsRYzTV#F$8k-XIcvVk2HY^L$L7C2upJd
zSxBE0F_^zrC6hmE(;0_>KmGlm#B@J)G=Mk^(5bOB0$8}?Mlw^;NE8jgGpIB_DmCg;
z7JVYA3@Sa68Vy4ov4fdl@B|`7wy|p+fx#d>D0Bv%LI6C_&W0d|IguESw6sE5Ik?)R
z?H%`9TUxqVyY92Mb#y>G*t()n_Et9gK4H;RLM$1eFg{_!|HQg|5i4s3ax`ce4bX^j
zK=^(dl?+=C8cF=JFZ;g8_Xjro%f6t#h(&<TAY`Tek5Yfy0{cg{{M@+U!{^oqC}6+S
zz{Wli9A^rFs0?|aUHw>3CkDtz`o&O1AAx(7dU6~xV|guUU?siZP9aLfX=;lbhq%gV
z_NPmOT5Ap!ol;)L7fzN`w}tj3$Xm#NlMUI7gUG3_i|@iV$f<%t8NMzc$qLGFP{8;f
zg*+5o$wU8sTW(!!#(}HPU~a~8eGtMv(AU3~<ce@J-w<zbxvKHz(O-vG*m}mkHr$sx
za=msEwkjpN2{M6;uY{{B(ysZHxjJd?&KPAL3z>87^j10U-`u$`M%S7(cn^?`0{0g}
z_$C1Blz-M-9-1FWt?JvmQ`FkuJfT7ovh_@Pkw;m#5|rUxrDc*t&8AvCRa>k}Q0Wu*
zR@G!-jutTYj^v?^kcW<CzFNG{d_EPVKcW<nwS9%;LS|uj>8!r@RgvhhY1NK0m#9bw
z%woW~nPWMW{cCoOEmfA8!8#$z@If<#s8nNRtS(RbVD@>?8yxBPJu=R=U=8zhR^F}{
zhIOHTY<{-?kCpK5pZ(7hSq6Mo;iSH!`qBNUAM^$_wC=-mQ|o-RPaB5^Wac?(ti8P6
z;wf`b(ouFoC7(Sz(@H>YL!_HDK5EeA*csfYY8;g`)=l(Ci|@Yo%xzG~j=FVswK23g
zEg)ugu`aLf47y2jnq1{W*RLF&n^?}FW?x;-pKI|vEp$8aG+C>uI(v(YV~$sbaQ1l2
zja2NG@&<WmHbGDtKaDcja&k$udS7a~kEI2$&!%FP#ivuVZ@<tSxo41$ZevGH=!C?u
z_5`;|Tw5i_UgLvnmgingjgGgitqv-Rz5U{z>x2Z}aJf_9ua3}yZudE|araLp87`ti
z`^K{;@(XVa+y1O)Y{1v9$}$3=F$p)QgnsGSE5jHtT(^?OMfV(5KVs@TC3W6pc~KR~
zVwP{-*XPfv3ljK*7*CJ&d#^0INI02Tosg@1^T(9^UV~Ek^YL?)scP(}Qd6cnZ(plt
zSj?`nS4O~bkEHEd4PbPG;{&0?YU(^b&5yYWjubOm>8#@4dx>j;wQ06)2>u?c;Zpo#
zWqAFA*`=Y)p)R^q>W*A=?+7RBm*Qcq7754Y-KDQ_jFLgGeAeRqX11or5^ug@_@WlR
zmUSw%4?c4UJRO-%8yoBHeE-sE$G0!1S0OE-QLM=`795Zlc@&-4+(Zo0Jo%g8EyA`j
z-K2*xCHlF?1#ZqHeyAL-O$7MHxAxscXbPVC<L*!LBva}<c6$YasiCeVepsuCbBYnf
zE9A%ZRga+aigWiXk#%wNX)5pnu-!AhQu?5MEj^r(I~%E^Z!?>X*{jV<ynVGIZ(*fP
z&NIxlaK!DU$&<&aipdI1Jyr+BiKpaLIV1ll3I7^*zbR|7<flC}dFbrHBO6J~=+ish
z1@9Gln04I6UkBO)`sVsZm!guMneOQ_36zKS+jUH|#0B-0Ja{5UVv>eMTH=<k;GX+l
z_i?1NGQMcNK>9|}4vS=km8!vTL^k%i5dd;~h)kZ``e;RD5_q6P8UFeShyUjMFAlT=
z=WqXTlBj2#chl$q=xS!Gp7Cz}kfEqN%eU$qb$GHuVvpUyfVG2Ru1N_)zEyJEBA1;c
zY>{ifNg#7%_EO=!Gx4SYbF^v?X3Bh=5%)^rtLmI!H*a#TE-uwc72Xw|Ke`~9_1P2k
z{4N)$zZKk>)DZHzQ}6Qm%<DTY9}Nt2@hWe7D}J*#!U<nC#U16ucf96q4|<$z%y($Z
zh_+F=@X+dsMR-6!)|FMevt{FJwH@VP-&W)+KqFY1SwOF3R;k=(PwY?jKO!j^w)T9z
zT}FI|l&XZL8sbw^c8%r~zE%x?f7x*Ny^yacx;paEh{KQcr|>sJuSf=vq}scjlSN{g
zqxD*tP-^p`omZA(&IpHwW9y_|g$qf5><GEFAnM)wix^Yvm2Il^p=vIJo)?b0Q4yI2
z-QZRD0oL}ie6eEm8)yup_;2^{mMm#n=}O~48FJ}GFB}Xx^n;H{^Xkn-#@VIe>0{Ec
z{86_jZQ^)IT7<5S;%%=54KPJM?wIU1ucS3<z8BFCCLev+BFT^7-r4CU{&sog#9*DI
z7da9bwh7+#mXr1Uy>zRGnz7D0KNsKK<n6f_WBI;mrsG|G!9wM(hpfxzWQ7jiMh&$|
zSU9#<{#iruq)<3?=f+&YSd&h{Guq|Oi~6{YMQ4NozW!?s@h0U<lisVP!l4C!eEpj~
z4ximluK?4pot6~Pzr1;^F^P1%7OuFRC*VBdP`Aw_)a##6hM$M;5Yd=<`1T6HU|QfL
z_s$?R@E@oGBUFI`bffR1Sd;ALz2%{ziaHRfW$QM!pr*f?3o6+1<)NsUI?l;c0rAJ1
z087^9=9zD&gx8odV9Hh~lPTF6_p3c1?%$g79D7<4GQoL1B1>gzRMM-^-UCUU6-+b5
z?MeC8dV23Um3?W|))LobAq;LGxSW&JZ6yij-f8nnj2<cvN_8TE39CosXYuq8Y8AAW
z+c%zCFsmM4+6hg0zBN6#Zt$p`jrNQaJgz?JyS&NsWkCCnd9U8-&g=39Z<a1k6^0%V
zSd3bHa4pJgc>C7jRH<T?LANPL$eZuqvU(CXz_Z6}nz^QbSm2Ton?o6YYwq5A!AhJW
z$9*d*M@5WJjjHDOQ|qjLiT6YQ*xpzoZjkj$b2e6~q4Qv&xnge(x5-ySLWyhAoq_YU
zzhlptREy8t3vder*n@W$0!|&i!u^QBXJVB?nK}MbD%WaCD#wPqgMqQG4zWSE4z87;
zzII1c-j*}hO&gd@Oe|;6ojtn(`)a;o&kteo4+Jyg(_;)$KL^qElw%LJkY(dc+1{>%
zp+miCW50F!CA$@h$_q?7W+umSZ>Vlq>1#FpQ2j>rh8kOZTgODi8hvyNtb)@T<8ts+
zQAmGw)VqO)LMha{^vpeptSp&W!T6TSg@I1)v6}Cvy7x5a8V^7JcyK^D+h@7ciyk$&
zYI|osUNCUM`g;|^sJ~LZvm_C^>|EJWtP1!~#@nOLJ@pD$f79xh7ct+OMYKyx{<VV5
zI%Tm1>+!-+8SYRw?cLN9(O!M~2fPIs!<{f~Fk@j3T3PxyIn~q_+0D9f7f)}Ob;3M;
zZh(^k2!WN5e|gi>tLFZB`^CsxMb;Q5bT>R1@K8oAj!w95OIAo3lF8Oh6*;okMeVm|
z=k&lw_uaBSR67k;N>#B{wv~ckH+!k=!EgGGpfTSN;q{xEI14o$(6nM}S_G)Fi}&n;
z5kE}(m-{gOKera&9eK3+5PrRAn6Pp`ST1$xalV(!^OI%c^(N&u-Mw({cQ1%+K*kzU
z`StJMz?E9pFKnzBuP_k@hGYWD2$>7BSmmpHcQxu<q+gc5ZCUU);6-d7*Zlf`+0|gV
z{~rFNo>6*}7uXYR8_2(h_B`{4z&CC9%Rx{!K>i<7<X?=EGKTHqH3e-5Kg9y2<QaBJ
Q_7Bj*%?o{PU)T@-1_Re;XaE2J

diff --git a/public/images/nothumb.svg b/public/images/nothumb.svg
new file mode 100644
index 0000000..339274a
--- /dev/null
+++ b/public/images/nothumb.svg
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 150">
+<defs><style>.cls-2{fill:#cc9d9d;}</style></defs>
+<g>
+	<path class="cls-2" d="m221.34,135.39c-13.69,0-27.38-.09-41.07.06-3.43.04-4.94-.61-4.91-4.56.17-27.21.14-54.42.02-81.63-.02-3.41.9-4.57,4.45-4.56,27.69.13,55.38.12,83.07,0,3.36-.01,4.19,1.18,4.18,4.34-.1,27.37-.12,54.73.02,82.1.02,3.73-1.44,4.34-4.69,4.3-13.69-.14-27.38-.06-41.07-.06Zm-.11-27.1c11.37,0,22.74-.1,34.1.06,3.26.05,4.3-.97,4.28-4.25-.14-16.19-.14-32.38,0-48.56.03-3.28-1.01-4.27-4.27-4.25-22.74.12-45.47.12-68.21,0-3.26-.02-4.3.97-4.27,4.25.14,16.19.14,32.38,0,48.56-.03,3.28,1.01,4.3,4.27,4.26,11.37-.16,22.74-.06,34.1-.06Z"/>
+	<path class="cls-2" d="m271.69,111.12c.4-3.72-.27-8.33-.9-12.95-.4-2.96.59-3.73,3.62-3.01,6.71,1.61,6.75,1.45,8.74-5.81,3.66-13.3,7.37-26.59,10.95-39.91,1.64-6.09,1.55-6.23-4.53-7.87-20.8-5.63-41.65-11.12-62.43-16.82-3.48-.95-5.32-.26-6.11,3.33-.73,3.33-1.85,6.57-2.55,9.9-.71,3.39-3,4.22-5.87,3.73-3.34-.57-2.27-2.94-1.71-5.06,1.7-6.44,3.31-12.91,5.03-19.34.47-1.74.7-3.35,3.66-2.54,27.36,7.52,54.77,14.85,82.2,22.1,2.71.72,3.31,1.43,2.52,4.29-7.26,26.45-14.3,52.97-21.49,79.44-.5,1.84-.24,5.23-3.51,4.25-3.05-.92-8.22.3-7.68-5.77.21-2.32.03-4.67.03-7.96Z"/>
+	<path class="cls-2" d="m237.89,68.65c3.58,9.04,7.13,18.07,10.74,27.08.87,2.17.4,3.25-2.07,3.25-16.63-.01-33.25,0-49.88-.01-2.63,0-2.8-1.35-1.8-3.33.7-1.39,1.37-2.79,2.07-4.17,2.84-5.69,2.92-5.78,8.04-1.6,1.77,1.44,2.44,1.1,3.45-.67,1.69-2.95,3.7-5.72,5.45-8.64,1.39-2.31,2.67-2.5,4.73-.62,2.11,1.93,3.79,5.97,6.49,5.2,2.2-.63,3.51-4.41,5.19-6.81,2.13-3.04,4.23-6.1,6.37-9.13.15-.21.54-.25,1.23-.54Z"/>
+	<path class="cls-2" d="m201.38,75.62c-3.33.17-5.32-1.1-5.41-4.73-.09-3.64,1.37-6.17,5.12-6.38,3.38-.19,5.57,1.83,6,5.22.4,3.09-2.39,5.81-5.72,5.89Z"/>
+</g>
+</svg>
diff --git a/templates/AlbumIndex.php b/templates/AlbumIndex.php
index 56635a4..3205b81 100644
--- a/templates/AlbumIndex.php
+++ b/templates/AlbumIndex.php
@@ -55,11 +55,12 @@ class AlbumIndex extends Template
 					echo '
 								<img src="', $thumbs[1], '"' . (isset($thumbs[2]) ?
 									' srcset="' . $thumbs[2] . ' 2x"' : '') .
-									' alt="">';
+									' alt="" style="width: ', static::TILE_WIDTH,
+										'px; height: ', static::TILE_HEIGHT, 'px">';
 				}
 				else
 					echo '
-								<img src="', BASEURL, '/images/nothumb.png" alt="">';
+								<img src="', BASEURL, '/images/nothumb.svg" alt="">';
 
 				if ($this->show_labels)
 					echo '
diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php
index 7fbdf55..29fa06a 100644
--- a/templates/PhotosIndex.php
+++ b/templates/PhotosIndex.php
@@ -100,7 +100,7 @@ class PhotosIndex extends Template
 		else
 			echo ' srcset="', $image->getThumbnailUrl($image->width(), $image->height(), true), ' 2x"';
 
-		echo ' alt="" title="', $image->getTitle(), '" style="aspect-ratio: ', $image->ratio(), '">';
+		echo ' alt="" title="', $image->getTitle(), '" style="width: ', $width, 'px; height: ', $height, 'px">';
 
 		if ($this->show_labels)
 			echo '
-- 
2.46.0


From 4d47696dcd9050bedb2998ea0302bcbe3a836821 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:20:22 +0100
Subject: [PATCH 26/79] Use Coda font for page links, too

---
 public/css/default.css | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/public/css/default.css b/public/css/default.css
index 3c60ef9..4967e99 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -36,6 +36,7 @@ a:hover {
 
 .page-link {
 	color: #b50707;
+	font-family: 'Coda', sans-serif;
 }
 .page-link:hover {
 	color: #a40d0d;
@@ -46,7 +47,7 @@ a:hover {
 }
 
 .btn {
-	font-family: 'Coda', 'sans-serif';
+	font-family: 'Coda', sans-serif;
 }
 .btn-primary {
 	--bs-btn-bg: #6c757d;
-- 
2.46.0


From 4033a8813cc8f58a6d15a89c6d1a974aafc1d51f Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:23:44 +0100
Subject: [PATCH 27/79] EditTag: hide option for assigning parent

---
 controllers/EditTag.php | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index 4b42926..adec592 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -65,10 +65,6 @@ class EditTag extends HTMLController
 			'request_url' => BASEURL . '/edittag/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
 			'content_below' => $after_form,
 			'fields' => [
-				'id_parent' => [
-					'type' => 'numeric',
-					'label' => 'Parent tag ID',
-				],
 				'id_asset_thumb' => [
 					'type' => 'numeric',
 					'label' => 'Thumbnail asset ID',
@@ -118,6 +114,7 @@ class EditTag extends HTMLController
 				return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
 
 			$data = $form->getData();
+			$data['id_parent'] = 0;
 
 			// Quick stripping.
 			$data['slug'] = strtr($data['slug'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']);
-- 
2.46.0


From 4684482d6719ba0b2b2c147597ff606daf705f52 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:28:21 +0100
Subject: [PATCH 28/79] ManageAlbums: move hierarchy logic to PhotoAlbum model

---
 controllers/ManageAlbums.php | 61 +----------------------------
 models/PhotoAlbum.php        | 76 ++++++++++++++++++++++++++++++++++++
 2 files changed, 77 insertions(+), 60 deletions(-)
 create mode 100644 models/PhotoAlbum.php

diff --git a/controllers/ManageAlbums.php b/controllers/ManageAlbums.php
index 6f578b1..662714b 100644
--- a/controllers/ManageAlbums.php
+++ b/controllers/ManageAlbums.php
@@ -68,28 +68,7 @@ class ManageAlbums extends HTMLController
 				if (!in_array($direction, ['up', 'down']))
 					$direction = 'up';
 
-				$db = Registry::get('db');
-				$res = $db->query('
-					SELECT *
-					FROM tags
-					WHERE kind = {string:album}
-					ORDER BY id_parent, {raw:order}',
-					[
-						'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'),
-						'album' => 'Album',
-					]);
-
-				$albums_by_parent = [];
-				while ($row = $db->fetch_assoc($res))
-				{
-					if (!isset($albums_by_parent[$row['id_parent']]))
-						$albums_by_parent[$row['id_parent']] = [];
-
-					$albums_by_parent[$row['id_parent']][] = $row + ['children' => []];
-				}
-
-				$albums = self::getChildrenRecursively(0, 0, $albums_by_parent);
-				$rows = self::flattenChildrenRecursively($albums);
+				$rows = PhotoAlbum::getHierarchy($order, $direction);
 
 				return [
 					'rows' => $rows,
@@ -106,42 +85,4 @@ class ManageAlbums extends HTMLController
 		parent::__construct('Album management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE);
 		$this->page->adopt(new TabularData($table));
 	}
-
-	private static function getChildrenRecursively($id_parent, $level, &$albums_by_parent)
-	{
-		$children = [];
-		if (!isset($albums_by_parent[$id_parent]))
-			return $children;
-
-		foreach ($albums_by_parent[$id_parent] as $child)
-		{
-			if (isset($albums_by_parent[$child['id_tag']]))
-				$child['children'] = self::getChildrenRecursively($child['id_tag'], $level + 1, $albums_by_parent);
-
-			$child['tag'] = ($level ? str_repeat('—', $level * 2) . ' ' : '') . $child['tag'];
-			$children[] = $child;
-		}
-
-		return $children;
-	}
-
-	private static function flattenChildrenRecursively($albums)
-	{
-		if (empty($albums))
-			return [];
-
-		$rows = [];
-		foreach ($albums as $album)
-		{
-			$rows[] = array_intersect_key($album, array_flip(['id_tag', 'tag', 'slug', 'count']));
-			if (!empty($album['children']))
-			{
-				$children = self::flattenChildrenRecursively($album['children']);
-				foreach ($children as $child)
-					$rows[] = array_intersect_key($child, array_flip(['id_tag', 'tag', 'slug', 'count']));
-			}
-		}
-
-		return $rows;
-	}
 }
diff --git a/models/PhotoAlbum.php b/models/PhotoAlbum.php
new file mode 100644
index 0000000..efdc41f
--- /dev/null
+++ b/models/PhotoAlbum.php
@@ -0,0 +1,76 @@
+<?php
+/*****************************************************************************
+ * PhotoAlbum.php
+ * Contains key class PhotoAlbum.
+ *
+ * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ *****************************************************************************/
+
+class PhotoAlbum extends Tag
+{
+	public static function getHierarchy($order, $direction)
+	{
+		$db = Registry::get('db');
+		$res = $db->query('
+			SELECT *
+			FROM tags
+			WHERE kind = {string:album}
+			ORDER BY id_parent, {raw:order}',
+			[
+				'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'),
+				'album' => 'Album',
+			]);
+
+		$albums_by_parent = [];
+		while ($row = $db->fetch_assoc($res))
+		{
+			if (!isset($albums_by_parent[$row['id_parent']]))
+				$albums_by_parent[$row['id_parent']] = [];
+
+			$albums_by_parent[$row['id_parent']][] = $row + ['children' => []];
+		}
+
+		$albums = self::getChildrenRecursively(0, 0, $albums_by_parent);
+		$rows = self::flattenChildrenRecursively($albums);
+
+		return $rows;
+	}
+
+	private static function getChildrenRecursively($id_parent, $level, &$albums_by_parent)
+	{
+		$children = [];
+		if (!isset($albums_by_parent[$id_parent]))
+			return $children;
+
+		foreach ($albums_by_parent[$id_parent] as $child)
+		{
+			if (isset($albums_by_parent[$child['id_tag']]))
+				$child['children'] = self::getChildrenRecursively($child['id_tag'], $level + 1, $albums_by_parent);
+
+			$child['tag'] = ($level ? str_repeat('—', $level * 2) . ' ' : '') . $child['tag'];
+			$children[] = $child;
+		}
+
+		return $children;
+	}
+
+	private static function flattenChildrenRecursively($albums)
+	{
+		if (empty($albums))
+			return [];
+
+		$rows = [];
+		foreach ($albums as $album)
+		{
+			$rows[] = array_intersect_key($album, array_flip(['id_tag', 'tag', 'slug', 'count']));
+			if (!empty($album['children']))
+			{
+				$children = self::flattenChildrenRecursively($album['children']);
+				foreach ($children as $child)
+					$rows[] = array_intersect_key($child, array_flip(['id_tag', 'tag', 'slug', 'count']));
+			}
+		}
+
+		return $rows;
+	}
+}
-- 
2.46.0


From 54df35073daf6efce123ca30f5e06539fb0e4295 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:35:47 +0100
Subject: [PATCH 29/79] EditAlbum: make parent selection more intuitive

---
 controllers/EditAlbum.php | 21 +++++++++++++++++++--
 1 file changed, 19 insertions(+), 2 deletions(-)

diff --git a/controllers/EditAlbum.php b/controllers/EditAlbum.php
index 5888876..e2aa20c 100644
--- a/controllers/EditAlbum.php
+++ b/controllers/EditAlbum.php
@@ -61,13 +61,24 @@ class EditAlbum extends HTMLController
 		elseif (!$id_tag)
 			$after_form = '<button name="submit_and_new" class="btn">Save and add another</button>';
 
+		// Gather possible parents for this album to be filed into
+		$parentChoices = [0 => '-root-'];
+		foreach (PhotoAlbum::getHierarchy('tag', 'up') as $parent)
+		{
+			if (!empty($id_tag) && $parent['id_tag'] == $id_tag)
+				continue;
+
+			$parentChoices[$parent['id_tag']] = $parent['tag'];
+		}
+
 		$form = new Form([
 			'request_url' => BASEURL . '/editalbum/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
 			'content_below' => $after_form,
 			'fields' => [
 				'id_parent' => [
-					'type' => 'numeric',
-					'label' => 'Parent album ID',
+					'type' => 'select',
+					'label' => 'Parent album',
+					'options' => $parentChoices,
 				],
 				'id_asset_thumb' => [
 					'type' => 'numeric',
@@ -127,6 +138,12 @@ class EditAlbum extends HTMLController
 
 			$data = $form->getData();
 
+			// Sanity check: don't let an album be its own parent
+			if ($data['id_parent'] == $id_tag)
+			{
+				return $formview->adopt(new Alert('Invalid parent', 'An album cannot be its own parent.', 'danger'));
+			}
+
 			// Quick stripping.
 			$data['tag'] = htmlentities($data['tag']);
 			$data['description'] = htmlentities($data['description']);
-- 
2.46.0


From fa01bf8961cba7a59872a550a8f371b6b1b746c7 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:53:53 +0100
Subject: [PATCH 30/79] ManageAssets: trade filename for user uploaded field

---
 controllers/ManageAssets.php | 23 +++++++++++++++--------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/controllers/ManageAssets.php b/controllers/ManageAssets.php
index 463ede5..4598c8e 100644
--- a/controllers/ManageAssets.php
+++ b/controllers/ManageAssets.php
@@ -38,13 +38,17 @@ class ManageAssets extends HTMLController
 						'data' => 'filename',
 					],
 				],
-				'title' => [
-					'header' => 'Title',
+				'id_user_uploaded' => [
+					'header' => 'User uploaded',
 					'is_sortable' => true,
 					'parse' => [
-						'type' => 'value',
-						'link' => BASEURL . '/editasset/?id={ID_ASSET}',
-						'data' => 'title',
+						'type' => 'function',
+						'data' => function($row) {
+							if (!empty($row['first_name']))
+								return $row['first_name'] . ' ' . $row['surname'];
+							else
+								return 'n/a';
+						},
 					],
 				],
 				'dimensions' => [
@@ -69,12 +73,15 @@ class ManageAssets extends HTMLController
 			'items_per_page' => 30,
 			'base_url' => BASEURL . '/manageassets/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
-				if (!in_array($order, ['id_asset', 'title', 'subdir', 'filename']))
+				if (!in_array($order, ['id_asset', 'id_user_uploaded', 'title', 'subdir', 'filename']))
 					$order = 'id_asset';
 
 				$data = Registry::get('db')->queryAssocs('
-					SELECT id_asset, title, subdir, filename, image_width, image_height
-					FROM assets
+					SELECT a.id_asset, a.subdir, a.filename,
+						a.image_width, a.image_height,
+						u.id_user, u.first_name, u.surname
+					FROM assets AS a
+					LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user
 					ORDER BY {raw:order}
 					LIMIT {int:offset}, {int:limit}',
 					[
-- 
2.46.0


From a9a347c638b0f870102db5788a83bd5a5c2b7962 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 17:59:57 +0100
Subject: [PATCH 31/79] Adjust dropdown focus colours

---
 public/css/default.css | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/public/css/default.css b/public/css/default.css
index 4967e99..df3e65c 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -62,6 +62,10 @@ a:hover {
 	--bs-btn-disabled-border-color: #6c757d;
 }
 
+.dropdown-item.active, .dropdown-item:active {
+	background-color: #990b0b;
+}
+
 
 /* Navigation
 ---------------*/
-- 
2.46.0


From edc857f6fd91d5c52c9e0b01c4f3c7b0516d7164 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 18:22:27 +0100
Subject: [PATCH 32/79] EditTag: introduce featured thumbnail manager

---
 controllers/EditTag.php                | 11 +++++++
 public/css/default.css                 | 30 ++++++++++++++++++
 templates/FeaturedThumbnailManager.php | 42 ++++++++++++++++++++++++++
 3 files changed, 83 insertions(+)
 create mode 100644 templates/FeaturedThumbnailManager.php

diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index adec592..c3d1382 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -105,6 +105,17 @@ class EditTag extends HTMLController
 		$formview = new FormView($form, $form_title ?? '');
 		$this->page->adopt($formview);
 
+		if (!empty($id_tag))
+		{
+			list($assets, $num_assets) = AssetIterator::getByOptions([
+				'direction' => 'desc',
+				'limit' => 500,
+				'id_tag' => $id_tag,
+			], true);
+
+			$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $tag->id_asset_thumb : 0));
+		}
+
 		if (!empty($_POST))
 		{
 			$form->verify($_POST);
diff --git a/public/css/default.css b/public/css/default.css
index df3e65c..1732722 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -355,6 +355,36 @@ i.space-invader.alt-7::before {
 }
 
 
+/* Featured thumbnail selection
+---------------------------------*/
+#featuredThumbnail {
+	list-style: none;
+	margin: 2.5% 0 0;
+	padding: 0;
+	clear: both;
+	overflow: auto;
+}
+#featuredThumbnail li {
+	float: left;
+	width: 18%;
+	line-height: 0;
+	margin: 0 1% 2%;
+	min-width: 200px;
+	height: 149px;
+	position: relative;
+}
+#featuredThumbnail input {
+	position: absolute;
+	top: 0.5rem;
+	right: 0.5rem;
+	z-index: 100;
+}
+#featuredThumbnail img {
+	width: 100%;
+	box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.2);
+}
+
+
 /* Footer
 -----------*/
 footer {
diff --git a/templates/FeaturedThumbnailManager.php b/templates/FeaturedThumbnailManager.php
new file mode 100644
index 0000000..910ecdf
--- /dev/null
+++ b/templates/FeaturedThumbnailManager.php
@@ -0,0 +1,42 @@
+<?php
+/*****************************************************************************
+ * FeaturedThumbnailManager.php
+ * Contains the featured thumbnail manager template.
+ *
+ * Kabuki CMS (C) 2013-2021, Aaron van Geffen
+ *****************************************************************************/
+
+class FeaturedThumbnailManager extends SubTemplate
+{
+	private $assets;
+	private $currentThumbnailId;
+
+	public function __construct(AssetIterator $assets, $currentThumbnailId)
+	{
+		$this->assets = $assets;
+		$this->currentThumbnailId = $currentThumbnailId;
+	}
+
+	protected function html_content()
+	{
+		echo '
+			<h2>Select thumbnail</h2>
+			<ul id="featuredThumbnail">';
+
+		while ($asset = $this->assets->next())
+		{
+			$image = $asset->getImage();
+			echo '
+				<li>
+					<input class="form-check-input" type="radio" name="featuredThumbnail" value="', $image->getId(), '"',
+					$this->currentThumbnailId == $image->getId() ? ' checked' : '', '>
+					<img src="', $image->getThumbnailUrl(150, 100, 'top'), '" alt="" title="', $image->getTitle(), '" onclick="this.parentNode.children[0].checked = true">
+				</li>';
+		}
+
+		$this->assets->clean();
+
+		echo '
+				</ul>';
+	}
+}
-- 
2.46.0


From cf0b9ebaf9decf8e9bd823fd760c48054b4f69b0 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 19:34:01 +0100
Subject: [PATCH 33/79] LogInForm: change title to something #RU-like

---
 templates/LogInForm.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/LogInForm.php b/templates/LogInForm.php
index 0c28e70..cafb51e 100644
--- a/templates/LogInForm.php
+++ b/templates/LogInForm.php
@@ -27,7 +27,7 @@ class LogInForm extends SubTemplate
 	{
 		if (!empty($this->_title))
 			echo '
-						<h1 class="mb-4">Log in to your account</h1>';
+						<h1 class="mb-4">Press #RU to continue</h1>';
 
 		if (!empty($this->_subtemplates))
 		{
-- 
2.46.0


From a06902335bb839e4909fb5d57d870a314f53c018 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 19:34:52 +0100
Subject: [PATCH 34/79] Manage{Tags,Users}: add call to resetSessionToken

---
 controllers/ManageTags.php  | 2 ++
 controllers/ManageUsers.php | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/controllers/ManageTags.php b/controllers/ManageTags.php
index ff49780..26f2b49 100644
--- a/controllers/ManageTags.php
+++ b/controllers/ManageTags.php
@@ -14,6 +14,8 @@ class ManageTags extends HTMLController
 		if (!Registry::get('user')->isAdmin())
 			throw new NotAllowedException();
 
+		Session::resetSessionToken();
+
 		$options = [
 			'form' => [
 				'action' => BASEURL . '/edittag/',
diff --git a/controllers/ManageUsers.php b/controllers/ManageUsers.php
index cf53e24..1be66a5 100644
--- a/controllers/ManageUsers.php
+++ b/controllers/ManageUsers.php
@@ -14,6 +14,8 @@ class ManageUsers extends HTMLController
 		if (!Registry::get('user')->isAdmin())
 			throw new NotAllowedException();
 
+		Session::resetSessionToken();
+
 		$options = [
 			'form' => [
 				'action' => BASEURL . '/edituser/',
-- 
2.46.0


From 6d0aef4df659b7eddbed9d9e98c7e53ddb51c6e1 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 19:49:17 +0100
Subject: [PATCH 35/79] EditTag: allow updating the thumbnail visually

---
 controllers/EditTag.php                | 39 ++++++++++++++++++--------
 models/Tag.php                         |  4 ++-
 templates/FeaturedThumbnailManager.php |  6 +++-
 3 files changed, 36 insertions(+), 13 deletions(-)

diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index c3d1382..a26107f 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -10,14 +10,18 @@ class EditTag extends HTMLController
 {
 	public function __construct()
 	{
-		// Ensure it's just admins at this point.
-		if (!Registry::get('user')->isAdmin())
-			throw new NotAllowedException();
-
 		$id_tag = isset($_GET['id']) ? (int) $_GET['id'] : 0;
 		if (empty($id_tag) && !isset($_GET['add']))
 			throw new UnexpectedValueException('Requested tag not found or not requesting a new tag.');
 
+		if (!empty($id_tag))
+			$tag = Tag::fromId($id_tag);
+
+		// Are we allowed to edit this tag?
+		$user = Registry::get('user');
+		if (!($user->isAdmin() || $user->getUserId() == $tag->id_user_owner))
+			throw new NotAllowedException();
+
 		// Adding an tag?
 		if (isset($_GET['add']))
 		{
@@ -29,7 +33,6 @@ class EditTag extends HTMLController
 		elseif (isset($_GET['delete']))
 		{
 			// So far so good?
-			$tag = Tag::fromId($id_tag);
 			if (Session::validateSession('get') && $tag->kind !== 'Album' && $tag->delete())
 			{
 				header('Location: ' . BASEURL . '/managetags/');
@@ -41,7 +44,6 @@ class EditTag extends HTMLController
 		// Editing one, then, surely.
 		else
 		{
-			$tag = Tag::fromId($id_tag);
 			if ($tag->kind === 'Album')
 				trigger_error('Cannot edit tag: is actually an album.', E_USER_ERROR);
 
@@ -65,11 +67,6 @@ class EditTag extends HTMLController
 			'request_url' => BASEURL . '/edittag/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
 			'content_below' => $after_form,
 			'fields' => [
-				'id_asset_thumb' => [
-					'type' => 'numeric',
-					'label' => 'Thumbnail asset ID',
-					'is_optional' => true,
-				],
 				'kind' => [
 					'type' => 'select',
 					'label' => 'Kind of tag',
@@ -116,6 +113,26 @@ class EditTag extends HTMLController
 			$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $tag->id_asset_thumb : 0));
 		}
 
+		if (isset($_POST['changeThumbnail']))
+			$this->processThumbnail($tag);
+		elseif (!empty($_POST))
+			$this->processTagDetails($form, $id_tag, $tag);
+	}
+
+	private function processThumbnail($tag)
+	{
+		if (empty($_POST))
+			return;
+
+		$tag->id_asset_thumb = $_POST['featuredThumbnail'];
+		$tag->save();
+
+		header('Location: ' . BASEURL . '/edittag/?id=' . $tag->id_tag);
+		exit;
+	}
+
+	private function processTagDetails($form, $id_tag, $tag)
+	{
 		if (!empty($_POST))
 		{
 			$form->verify($_POST);
diff --git a/models/Tag.php b/models/Tag.php
index e6b6f13..d5bbccc 100644
--- a/models/Tag.php
+++ b/models/Tag.php
@@ -11,6 +11,7 @@ class Tag
 	public $id_tag;
 	public $id_parent;
 	public $id_asset_thumb;
+	public $id_user_owner;
 	public $tag;
 	public $slug;
 	public $description;
@@ -258,7 +259,8 @@ class Tag
 			UPDATE tags
 			SET
 				id_parent = {int:id_parent},
-				id_asset_thumb = {int:id_asset_thumb},
+				id_asset_thumb = {int:id_asset_thumb},' . (isset($this->id_user_owner) ? '
+				id_user_owner = {int:id_user_owner},' : '') . '
 				tag = {string:tag},
 				slug = {string:slug},
 				description = {string:description},
diff --git a/templates/FeaturedThumbnailManager.php b/templates/FeaturedThumbnailManager.php
index 910ecdf..ffbe1c0 100644
--- a/templates/FeaturedThumbnailManager.php
+++ b/templates/FeaturedThumbnailManager.php
@@ -20,6 +20,8 @@ class FeaturedThumbnailManager extends SubTemplate
 	protected function html_content()
 	{
 		echo '
+		<form action="" method="post">
+			<button class="btn btn-primary float-end" type="submit" name="changeThumbnail">Save thumbnail selection</button>
 			<h2>Select thumbnail</h2>
 			<ul id="featuredThumbnail">';
 
@@ -37,6 +39,8 @@ class FeaturedThumbnailManager extends SubTemplate
 		$this->assets->clean();
 
 		echo '
-				</ul>';
+				</ul>
+				<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">
+			</form>';
 	}
 }
-- 
2.46.0


From 59b1fa7a72a1bcbb16cd7f367573a5f93dec06f8 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 19:52:30 +0100
Subject: [PATCH 36/79] EditAlbum: allow updating the thumbnail visually

---
 controllers/EditAlbum.php | 41 ++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)

diff --git a/controllers/EditAlbum.php b/controllers/EditAlbum.php
index e2aa20c..76d549d 100644
--- a/controllers/EditAlbum.php
+++ b/controllers/EditAlbum.php
@@ -18,6 +18,9 @@ class EditAlbum extends HTMLController
 		if (empty($id_tag) && !isset($_GET['add']) && $_GET['action'] !== 'addalbum')
 			throw new UnexpectedValueException('Requested album not found or not requesting a new album.');
 
+		if (!empty($id_tag))
+			$album = Tag::fromId($id_tag);
+
 		// Adding an album?
 		if (isset($_GET['add']) || $_GET['action'] === 'addalbum')
 		{
@@ -29,7 +32,6 @@ class EditAlbum extends HTMLController
 		elseif (isset($_GET['delete']))
 		{
 			// So far so good?
-			$album = Tag::fromId($id_tag);
 			if (Session::validateSession('get') && $album->kind === 'Album' && $album->delete())
 			{
 				header('Location: ' . BASEURL . '/managealbums/');
@@ -41,7 +43,6 @@ class EditAlbum extends HTMLController
 		// Editing one, then, surely.
 		else
 		{
-			$album = Tag::fromId($id_tag);
 			if ($album->kind !== 'Album')
 				trigger_error('Cannot edit album: not an album.', E_USER_ERROR);
 
@@ -80,11 +81,6 @@ class EditAlbum extends HTMLController
 					'label' => 'Parent album',
 					'options' => $parentChoices,
 				],
-				'id_asset_thumb' => [
-					'type' => 'numeric',
-					'label' => 'Thumbnail asset ID',
-					'is_optional' => true,
-				],
 				'tag' => [
 					'type' => 'text',
 					'label' => 'Album title',
@@ -128,6 +124,37 @@ class EditAlbum extends HTMLController
 		$formview = new FormView($form, $form_title ?? '');
 		$this->page->adopt($formview);
 
+		if (!empty($id_tag))
+		{
+			list($assets, $num_assets) = AssetIterator::getByOptions([
+				'direction' => 'desc',
+				'limit' => 500,
+				'id_tag' => $id_tag,
+			], true);
+
+			$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0));
+		}
+
+		if (isset($_POST['changeThumbnail']))
+			$this->processThumbnail($album);
+		elseif (!empty($_POST))
+			$this->processTagDetails($form, $id_tag, $album);
+	}
+
+	private function processThumbnail($tag)
+	{
+		if (empty($_POST))
+			return;
+
+		$tag->id_asset_thumb = $_POST['featuredThumbnail'];
+		$tag->save();
+
+		header('Location: ' . BASEURL . '/editalbum/?id=' . $tag->id_tag);
+		exit;
+	}
+
+	private function processTagDetails($form, $id_tag, $album)
+	{
 		if (!empty($_POST))
 		{
 			$form->verify($_POST);
-- 
2.46.0


From ad816f10a36e6a6825d4fe89cc98c7c24866e867 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 19:57:19 +0100
Subject: [PATCH 37/79] EditTag: allow designating a tag owner

---
 controllers/EditTag.php |  5 +++++
 models/Member.php       | 11 +++++++++++
 2 files changed, 16 insertions(+)

diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index a26107f..d752223 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -75,6 +75,11 @@ class EditTag extends HTMLController
 						'Person' => 'Person',
 					],
 				],
+				'id_user_owner' => [
+					'type' => 'select',
+					'label' => 'Owner',
+					'options' => [0 => '(nobody)'] + Member::getMemberMap(),
+				],
 				'tag' => [
 					'type' => 'text',
 					'label' => 'Tag title',
diff --git a/models/Member.php b/models/Member.php
index 6675dac..84912a4 100644
--- a/models/Member.php
+++ b/models/Member.php
@@ -192,4 +192,15 @@ class Member extends User
 		// We should probably phase out the use of this function, or refactor the access levels of member properties...
 		return get_object_vars($this);
 	}
+
+	public static function getMemberMap()
+	{
+		return Registry::get('db')->queryPair('
+			SELECT id_user, CONCAT(first_name, {string:blank}, surname) AS full_name
+			FROM users
+			ORDER BY first_name, surname',
+			[
+				'blank' => ' ',
+			]);
+	}
 }
-- 
2.46.0


From 27f69b0a74728f48ebceb9b62b679b58742755de Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:01:25 +0100
Subject: [PATCH 38/79] EditTag: disallow users to disown their own tags

---
 controllers/EditTag.php | 76 +++++++++++++++++++++++------------------
 1 file changed, 42 insertions(+), 34 deletions(-)

diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index d752223..12de092 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -63,43 +63,51 @@ class EditTag extends HTMLController
 		elseif (!$id_tag)
 			$after_form = '<button name="submit_and_new" class="btn">Save and add another</button>';
 
+		$fields = [
+			'kind' => [
+				'type' => 'select',
+				'label' => 'Kind of tag',
+				'options' => [
+					'Location' => 'Location',
+					'Person' => 'Person',
+				],
+			],
+			'id_user_owner' => [
+				'type' => 'select',
+				'label' => 'Owner',
+				'options' => [0 => '(nobody)'] + Member::getMemberMap(),
+			],
+			'tag' => [
+				'type' => 'text',
+				'label' => 'Tag title',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'slug' => [
+				'type' => 'text',
+				'label' => 'URL slug',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'description' => [
+				'type' => 'textbox',
+				'label' => 'Description',
+				'size' => 50,
+				'maxlength' => 255,
+				'is_optional' => true,
+			],
+		];
+
+		if (!$user->isAdmin())
+		{
+			unset($fields['kind']);
+			unset($fields['id_user_owner']);
+		}
+
 		$form = new Form([
 			'request_url' => BASEURL . '/edittag/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
 			'content_below' => $after_form,
-			'fields' => [
-				'kind' => [
-					'type' => 'select',
-					'label' => 'Kind of tag',
-					'options' => [
-						'Location' => 'Location',
-						'Person' => 'Person',
-					],
-				],
-				'id_user_owner' => [
-					'type' => 'select',
-					'label' => 'Owner',
-					'options' => [0 => '(nobody)'] + Member::getMemberMap(),
-				],
-				'tag' => [
-					'type' => 'text',
-					'label' => 'Tag title',
-					'size' => 50,
-					'maxlength' => 255,
-				],
-				'slug' => [
-					'type' => 'text',
-					'label' => 'URL slug',
-					'size' => 50,
-					'maxlength' => 255,
-				],
-				'description' => [
-					'type' => 'textbox',
-					'label' => 'Description',
-					'size' => 50,
-					'maxlength' => 255,
-					'is_optional' => true,
-				],
-			],
+			'fields' => $fields,
 		]);
 
 		// Create the form, add in default values.
-- 
2.46.0


From daa8b051c57c3853ad5ae3c51bdf959239ec97da Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:03:09 +0100
Subject: [PATCH 39/79] EditTag: on saving, redirect users to a page they can
 see

---
 controllers/EditTag.php | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index 12de092..fcb0e96 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -182,8 +182,11 @@ class EditTag extends HTMLController
 				$tag->save();
 			}
 
-			// Redirect to the tag management page.
-			header('Location: ' . BASEURL . '/managetags/');
+			// Redirect to a clean page
+			if (Registry::get('user')->isAdmin())
+				header('Location: ' . BASEURL . '/managetags/');
+			else
+				header('Location: ' . BASEURL . '/edittag/?id=' . $id_tag);
 			exit;
 		}
 	}
-- 
2.46.0


From a76dde927b799dc5370601745d5bb583a2dfca86 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:27:09 +0100
Subject: [PATCH 40/79] AccountSettings: list tags owned by current user

---
 controllers/AccountSettings.php |  5 +++++
 models/Tag.php                  | 19 +++++++++++++++++++
 templates/MyTagsView.php        | 32 ++++++++++++++++++++++++++++++++
 3 files changed, 56 insertions(+)
 create mode 100644 templates/MyTagsView.php

diff --git a/controllers/AccountSettings.php b/controllers/AccountSettings.php
index 4bbb056..4f4a431 100644
--- a/controllers/AccountSettings.php
+++ b/controllers/AccountSettings.php
@@ -73,6 +73,11 @@ class AccountSettings extends HTMLController
 		$formview = new FormView($form, $form_title);
 		$this->page->adopt($formview);
 
+		// Fetch user tags
+		$tags = Tag::getAllByOwner($user->getUserId());
+		if (!empty($tags))
+			$this->page->adopt(new MyTagsView($tags));
+
 		// Left a message?
 		if (isset($_SESSION['account_msg']))
 		{
diff --git a/models/Tag.php b/models/Tag.php
index d5bbccc..13af5c7 100644
--- a/models/Tag.php
+++ b/models/Tag.php
@@ -96,6 +96,25 @@ class Tag
 			return $rows;
 	}
 
+	public static function getAllByOwner($id_user_owner)
+	{
+		$db = Registry::get('db');
+		$res = $db->query('
+			SELECT *
+			FROM tags
+			WHERE id_user_owner = {int:id_user_owner}
+			ORDER BY tag',
+			[
+				'id_user_owner' => $id_user_owner,
+			]);
+
+		$objects = [];
+		while ($row = $db->fetch_assoc($res))
+			$objects[$row['id_tag']] = new Tag($row);
+
+		return $objects;
+	}
+
 	public static function getAlbums($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array')
 	{
 		$rows = Registry::get('db')->queryAssocs('
diff --git a/templates/MyTagsView.php b/templates/MyTagsView.php
new file mode 100644
index 0000000..2b06fd9
--- /dev/null
+++ b/templates/MyTagsView.php
@@ -0,0 +1,32 @@
+<?php
+/*****************************************************************************
+ * MyTagsView.php
+ * Contains the user tag list.
+ *
+ * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ *****************************************************************************/
+
+class MyTagsView extends SubTemplate
+{
+	private $tags;
+
+	public function __construct(array $tags)
+	{
+		$this->tags = $tags;
+	}
+
+	protected function html_content()
+	{
+		echo '
+			<h2>Tags you can edit</h2>
+			<p>You can currently edit the tags below. Click a tag to edit it.</p>
+			<ul>';
+
+		foreach ($this->tags as $tag)
+			echo '
+				<li><a href="', BASEURL, '/edittag/?id=', $tag->id_tag, '">', $tag->tag, '</a></li>';
+
+		echo '
+			</ul>';
+	}
+}
-- 
2.46.0


From d9fd2ae20dc08b7e85dc8b4cde47c526684dd8fb Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:27:45 +0100
Subject: [PATCH 41/79] Add upgrade script for new tag ownership

---
 upgrade.sql | 15 +++++++++++++++
 1 file changed, 15 insertions(+)
 create mode 100644 upgrade.sql

diff --git a/upgrade.sql b/upgrade.sql
new file mode 100644
index 0000000..7e07e9c
--- /dev/null
+++ b/upgrade.sql
@@ -0,0 +1,15 @@
+/* 2023-03-11 Allow designating an owner for each tag */
+ALTER TABLE `tags` ADD `id_user_owner` INT  NULL  DEFAULT NULL  AFTER `id_asset_thumb`;
+
+/* 2023-03-11 Try to assign tag owners automagically */
+UPDATE tags AS t
+SET id_user_owner = (
+	SELECT id_user
+	FROM users AS u
+	WHERE LOWER(u.first_name) = LOWER(t.slug) OR
+		LOWER(u.first_name) = LOWER(t.tag) OR
+		LOWER(u.slug) = LOWER(t.slug) OR
+		LOWER(u.slug) = LOWER(t.tag)
+	)
+WHERE t.kind = 'Person' AND
+	(t.id_user_owner = 0 OR t.id_user_owner IS NULL);
-- 
2.46.0


From 167a50cb9281794d49df7bb4fb08ab3485279734 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:34:58 +0100
Subject: [PATCH 42/79] ViewPhotoAlbum: tweak album buttons to be more useful

---
 controllers/ViewPhotoAlbum.php | 75 ++++++++++++++++++++++++----------
 1 file changed, 54 insertions(+), 21 deletions(-)

diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php
index aa3d632..d45588b 100644
--- a/controllers/ViewPhotoAlbum.php
+++ b/controllers/ViewPhotoAlbum.php
@@ -60,27 +60,7 @@ class ViewPhotoAlbum extends HTMLController
 
 		// Can we do fancy things here?
 		// !!! TODO: permission system?
-		$buttons = [];
-
-		if (Registry::get('user')->isLoggedIn())
-		{
-			$buttons[] = [
-				'url' => BASEURL . '/download/?tag=' . $id_tag,
-				'caption' => 'Download this album',
-			];
-
-			$buttons[] = [
-				'url' => BASEURL . '/uploadmedia/?tag=' . $id_tag,
-				'caption' => 'Upload new photos here',
-			];
-		}
-		if (Registry::get('user')->isAdmin())
-			$buttons[] = [
-				'url' => BASEURL . '/addalbum/?tag=' . $id_tag,
-				'caption' => 'Create new subalbum here',
-			];
-
-		// Enough actions for a button box?
+		$buttons = $this->getAlbumButtons($id_tag, $tag ?? null);
 		if (!empty($buttons))
 			$this->page->adopt(new AlbumButtonBox($buttons));
 
@@ -164,6 +144,59 @@ class ViewPhotoAlbum extends HTMLController
 		return $albums;
 	}
 
+	private function getAlbumButtons($id_tag, $tag)
+	{
+		$buttons = [];
+		$user = Registry::get('user');
+
+		if ($user->isLoggedIn())
+		{
+			$buttons[] = [
+				'url' => BASEURL . '/download/?tag=' . $id_tag,
+				'caption' => 'Download album',
+			];
+		}
+
+		if (isset($tag))
+		{
+			if ($tag->kind === 'Album')
+			{
+				$buttons[] = [
+					'url' => BASEURL . '/uploadmedia/?tag=' . $id_tag,
+					'caption' => 'Upload photos here',
+				];
+			}
+
+			if ($user->isAdmin())
+			{
+				if ($tag->kind === 'Album')
+				{
+					$buttons[] = [
+						'url' => BASEURL . '/editalbum/?id=' . $id_tag,
+						'caption' => 'Edit album',
+					];
+				}
+				elseif ($tag->kind === 'Person')
+				{
+					$buttons[] = [
+						'url' => BASEURL . '/edittag/?id=' . $id_tag,
+						'caption' => 'Edit tag',
+					];
+				}
+			}
+		}
+
+		if ($user->isAdmin() && (!isset($tag) || $tag->kind === 'Album'))
+		{
+			$buttons[] = [
+				'url' => BASEURL . '/addalbum/?tag=' . $id_tag,
+				'caption' => 'Create subalbum',
+			];
+		}
+
+		return $buttons;
+	}
+
 	public function __destruct()
 	{
 		if (isset($this->iterator))
-- 
2.46.0


From 310fe7c3d6ddc5d50bc5b2da165c5ebd13fb635e Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:37:39 +0100
Subject: [PATCH 43/79] Hide thumbnail selection when none available

---
 controllers/EditAlbum.php | 3 ++-
 controllers/EditTag.php   | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/controllers/EditAlbum.php b/controllers/EditAlbum.php
index 76d549d..c24787d 100644
--- a/controllers/EditAlbum.php
+++ b/controllers/EditAlbum.php
@@ -132,7 +132,8 @@ class EditAlbum extends HTMLController
 				'id_tag' => $id_tag,
 			], true);
 
-			$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0));
+			if ($num_assets > 0)
+				$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0));
 		}
 
 		if (isset($_POST['changeThumbnail']))
diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index fcb0e96..c96ef0e 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -123,7 +123,8 @@ class EditTag extends HTMLController
 				'id_tag' => $id_tag,
 			], true);
 
-			$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $tag->id_asset_thumb : 0));
+			if ($num_assets > 0)
+				$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $tag->id_asset_thumb : 0));
 		}
 
 		if (isset($_POST['changeThumbnail']))
-- 
2.46.0


From 5cff62836e28dca61079c5e43e7a7a0c8daea59d Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:39:55 +0100
Subject: [PATCH 44/79] ManageTags: display owning user in table

---
 controllers/ManageTags.php | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/controllers/ManageTags.php b/controllers/ManageTags.php
index 26f2b49..cb1ffd2 100644
--- a/controllers/ManageTags.php
+++ b/controllers/ManageTags.php
@@ -50,10 +50,18 @@ class ManageTags extends HTMLController
 						'data' => 'slug',
 					],
 				],
-				'kind' => [
-					'header' => 'Kind',
+				'id_user_owner' => [
+					'header' => 'Owning user',
 					'is_sortable' => true,
-					'value' => 'kind',
+					'parse' => [
+						'type' => 'function',
+						'data' => function($row) {
+							if (!empty($row['first_name']))
+								return $row['first_name'] . ' ' . $row['surname'];
+							else
+								return 'n/a';
+						},
+					],
 				],
 				'count' => [
 					'header' => 'Cardinality',
@@ -76,8 +84,9 @@ class ManageTags extends HTMLController
 					$direction = 'up';
 
 				$data = Registry::get('db')->queryAssocs('
-					SELECT *
-					FROM tags
+					SELECT t.*, u.id_user, u.first_name, u.surname
+					FROM tags AS t
+					LEFT JOIN users AS u ON t.id_user_owner = u.id_user
 					WHERE kind != {string:album}
 					ORDER BY {raw:order}
 					LIMIT {int:offset}, {int:limit}',
-- 
2.46.0


From 5b8551a72676a08f1bdb698134600d6c0d65f786 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 20:46:31 +0100
Subject: [PATCH 45/79] EditAlbum: allow specifying a thumbnail ID manually if
 none are present

---
 controllers/EditAlbum.php | 87 ++++++++++++++++++++++-----------------
 1 file changed, 50 insertions(+), 37 deletions(-)

diff --git a/controllers/EditAlbum.php b/controllers/EditAlbum.php
index c24787d..6d73428 100644
--- a/controllers/EditAlbum.php
+++ b/controllers/EditAlbum.php
@@ -72,37 +72,58 @@ class EditAlbum extends HTMLController
 			$parentChoices[$parent['id_tag']] = $parent['tag'];
 		}
 
+		$fields = [
+			'id_parent' => [
+				'type' => 'select',
+				'label' => 'Parent album',
+				'options' => $parentChoices,
+			],
+			'id_asset_thumb' => [
+				'type' => 'numeric',
+				'label' => 'Thumbnail asset ID',
+				'is_optional' => true,
+			],
+			'tag' => [
+				'type' => 'text',
+				'label' => 'Album title',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'slug' => [
+				'type' => 'text',
+				'label' => 'URL slug',
+				'size' => 50,
+				'maxlength' => 255,
+			],
+			'description' => [
+				'type' => 'textbox',
+				'label' => 'Description',
+				'size' => 50,
+				'maxlength' => 255,
+				'is_optional' => true,
+			],
+		];
+
+		// Fetch image assets for this album
+		if (!empty($id_tag))
+		{
+			list($assets, $num_assets) = AssetIterator::getByOptions([
+				'direction' => 'desc',
+				'limit' => 500,
+				'id_tag' => $id_tag,
+			], true);
+
+			if ($num_assets > 0)
+				unset($fields['id_asset_thumb']);
+		}
+
 		$form = new Form([
 			'request_url' => BASEURL . '/editalbum/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
 			'content_below' => $after_form,
-			'fields' => [
-				'id_parent' => [
-					'type' => 'select',
-					'label' => 'Parent album',
-					'options' => $parentChoices,
-				],
-				'tag' => [
-					'type' => 'text',
-					'label' => 'Album title',
-					'size' => 50,
-					'maxlength' => 255,
-				],
-				'slug' => [
-					'type' => 'text',
-					'label' => 'URL slug',
-					'size' => 50,
-					'maxlength' => 255,
-				],
-				'description' => [
-					'type' => 'textbox',
-					'label' => 'Description',
-					'size' => 50,
-					'maxlength' => 255,
-					'is_optional' => true,
-				],
-			],
+			'fields' => $fields,
 		]);
 
+		// Add defaults for album if none present
 		if (empty($_POST) && isset($_GET['tag']))
 		{
 			$parentTag = Tag::fromId($_GET['tag']);
@@ -124,17 +145,9 @@ class EditAlbum extends HTMLController
 		$formview = new FormView($form, $form_title ?? '');
 		$this->page->adopt($formview);
 
-		if (!empty($id_tag))
-		{
-			list($assets, $num_assets) = AssetIterator::getByOptions([
-				'direction' => 'desc',
-				'limit' => 500,
-				'id_tag' => $id_tag,
-			], true);
-
-			if ($num_assets > 0)
-				$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0));
-		}
+		// If we have asset images, show the thumbnail manager
+		if (!empty($id_tag) && $num_assets > 0)
+			$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0));
 
 		if (isset($_POST['changeThumbnail']))
 			$this->processThumbnail($album);
-- 
2.46.0


From a4cc5289518524f95fdf73a49ab54ed12d8b1869 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:24:55 +0100
Subject: [PATCH 46/79] ManageAssets: allow batch deletion of assets

---
 controllers/ManageAssets.php         | 50 ++++++++++++++++++++++++++--
 templates/AssetManagementWrapper.php | 36 ++++++++++++++++++++
 templates/TabularData.php            | 19 +++++++++--
 3 files changed, 100 insertions(+), 5 deletions(-)
 create mode 100644 templates/AssetManagementWrapper.php

diff --git a/controllers/ManageAssets.php b/controllers/ManageAssets.php
index 4598c8e..499b3c1 100644
--- a/controllers/ManageAssets.php
+++ b/controllers/ManageAssets.php
@@ -14,10 +14,37 @@ class ManageAssets extends HTMLController
 		if (!Registry::get('user')->isAdmin())
 			throw new NotAllowedException();
 
+		if (isset($_POST['deleteChecked'], $_POST['delete']) && Session::validateSession())
+			$this->handleAssetDeletion();
+
 		Session::resetSessionToken();
 
 		$options = [
+			'form' => [
+				'action' => BASEURL . '/manageassets/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
+				'method' => 'post',
+				'class' => 'col-md-6 text-end',
+				'is_embed' => true,
+				'buttons' => [
+					'deleteChecked' => [
+						'type' => 'submit',
+						'caption' => 'Delete checked',
+						'class' => 'btn-danger',
+						'onclick' => 'return confirm(\'Are you sure you want to delete these items?\')',
+					],
+				],
+			],
 			'columns' => [
+				'checkbox' => [
+					'header' => '<input type="checkbox" id="selectall">',
+					'is_sortable' => false,
+					'parse' => [
+						'type' => 'function',
+						'data' => function($row) {
+							return '<input type="checkbox" class="asset_select" name="delete[]" value="' . $row['id_asset'] . '">';
+						},
+					],
+				],
 				'id_asset' => [
 					'value' => 'id_asset',
 					'header' => 'ID',
@@ -71,6 +98,7 @@ class ManageAssets extends HTMLController
 			'title' => 'Manage assets',
 			'no_items_label' => 'No assets meet the requirements of the current filter.',
 			'items_per_page' => 30,
+			'index_class' => 'col-md-6',
 			'base_url' => BASEURL . '/manageassets/',
 			'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
 				if (!in_array($order, ['id_asset', 'id_user_uploaded', 'title', 'subdir', 'filename']))
@@ -100,7 +128,25 @@ class ManageAssets extends HTMLController
 		];
 
 		$table = new GenericTable($options);
-		parent::__construct('Asset management - Page ' . $table->getCurrentPage() .'');
-		$this->page->adopt(new TabularData($table));
+		parent::__construct('Asset management - Page ' . $table->getCurrentPage());
+
+		$wrapper = new AssetManagementWrapper();
+		$this->page->adopt($wrapper);
+		$wrapper->adopt(new TabularData($table));
+	}
+
+	private function handleAssetDeletion()
+	{
+		if (!isset($_POST['delete']) || !is_array($_POST['delete']))
+			throw new UnexpectedValueException();
+
+		foreach ($_POST['delete'] as $id_asset)
+		{
+			$asset = Asset::fromId($id_asset);
+			$asset->delete();
+		}
+
+		header('Location: ' . BASEURL . '/manageassets/');
+		exit;
 	}
 }
diff --git a/templates/AssetManagementWrapper.php b/templates/AssetManagementWrapper.php
new file mode 100644
index 0000000..545f7c4
--- /dev/null
+++ b/templates/AssetManagementWrapper.php
@@ -0,0 +1,36 @@
+<?php
+/*****************************************************************************
+ * AssetManagementWrapper.php
+ * Defines asset management wrapper template.
+ *
+ * Kabuki CMS (C) 2013-2015, Aaron van Geffen
+ *****************************************************************************/
+
+class AssetManagementWrapper extends Template
+{
+	public function html_main()
+	{
+		echo '
+		<form action="" method="post">';
+
+		foreach ($this->_subtemplates as $template)
+			$template->html_main();
+
+		echo '
+		</form>
+		<script type="text/javascript" defer="defer">
+			const allAreSelected = () => {
+				return document.querySelectorAll(".asset_select").length ===
+					document.querySelectorAll(".asset_select:checked").length;
+			};
+
+			const selectAll = document.getElementById("selectall");
+			selectAll.addEventListener("change", event => {
+				const newSelectedState = !allAreSelected();
+				document.querySelectorAll(".asset_select").forEach(el => {
+					el.checked = newSelectedState;
+				});
+			});
+		</script>';
+	}
+}
diff --git a/templates/TabularData.php b/templates/TabularData.php
index 86f17de..fdcfed3 100644
--- a/templates/TabularData.php
+++ b/templates/TabularData.php
@@ -145,8 +145,12 @@ class TabularData extends SubTemplate
 
 	protected function showForm($form)
 	{
-		echo '
+		if (!isset($form['is_embed']))
+			echo '
 			<form action="', $form['action'], '" method="', $form['method'], '" class="', $form['class'], '">';
+		else
+			echo '
+			<div class="', $form['class'], '">';
 
 		if (!empty($form['is_group']))
 			echo '
@@ -206,7 +210,12 @@ class TabularData extends SubTemplate
 			foreach ($form['buttons'] as $name => $button)
 			{
 				echo '
-					<button class="btn ', isset($button['class']) ? $button['class'] : 'btn-primary', '" type="', $button['type'], '" name="', $name, '">', $button['caption'], '</button>';
+					<button class="btn ', isset($button['class']) ? $button['class'] : 'btn-primary', '" type="', $button['type'], '" name="', $name, '"';
+
+				if (isset($button['onclick']))
+					echo ' onclick="', $button['onclick'], '"';
+
+				echo '>', $button['caption'], '</button>';
 
 				if (isset($button['html_after']))
 					echo $button['html_after'];
@@ -216,7 +225,11 @@ class TabularData extends SubTemplate
 			echo '
 				</div>';
 
-		echo '
+		if (!isset($form['is_embed']))
+			echo '
 			</form>';
+		else
+			echo '
+			</div>';
 	}
 }
-- 
2.46.0


From 0b0d47acb83309df8eb13ba51d6bab81c5269c15 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:36:32 +0100
Subject: [PATCH 47/79] UploadQueue: error out of HEIC files are presented

---
 public/js/upload_queue.js | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/public/js/upload_queue.js b/public/js/upload_queue.js
index 75b237d..a0953ff 100644
--- a/public/js/upload_queue.js
+++ b/public/js/upload_queue.js
@@ -17,6 +17,14 @@ UploadQueue.prototype.addEvents = function() {
 				that.hideSpinner();
 				that.submit.disabled = false;
 			};
+
+			if (that.queue.files[0].name.includes(".HEIC")) {
+				alert('Sorry, the HEIC image format is not supported.\nPlease convert your photos to JPEG before uploading.');
+				that.hideSpinner();
+				that.submit.disabled = false;
+				break;
+			}
+
 			that.addPreviewBoxForQueueSlot(i);
 			that.addPreviewForFile(that.queue.files[i], i, callback);
 		};
-- 
2.46.0


From aa3a54f237cbf1907c8989fac34b5a2f2c016851 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:39:20 +0100
Subject: [PATCH 48/79] Asset: guard using property_exists in constructor

---
 models/Asset.php | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/models/Asset.php b/models/Asset.php
index 483c799..bdf6b38 100644
--- a/models/Asset.php
+++ b/models/Asset.php
@@ -27,7 +27,10 @@ class Asset
 	protected function __construct(array $data)
 	{
 		foreach ($data as $attribute => $value)
-			$this->$attribute = $value;
+		{
+			if (property_exists($this, $attribute))
+				$this->$attribute = $value;
+		}
 
 		if (!empty($data['date_captured']) && $data['date_captured'] !== 'NULL')
 			$this->date_captured = new DateTime($data['date_captured']);
-- 
2.46.0


From 1b7e745f1135d06054fefa80a3e1148c22410823 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:41:23 +0100
Subject: [PATCH 49/79] Clean up Tag::resetIdAsset

---
 models/Tag.php | 11 ++---------
 1 file changed, 2 insertions(+), 9 deletions(-)

diff --git a/models/Tag.php b/models/Tag.php
index 13af5c7..14696f3 100644
--- a/models/Tag.php
+++ b/models/Tag.php
@@ -313,8 +313,7 @@ class Tag
 	public function resetIdAsset()
 	{
 		$db = Registry::get('db');
-
-		$row = $db->query('
+		$new_id = $db->queryValue('
 			SELECT MAX(id_asset) as new_id
 			FROM assets_tags
 			WHERE id_tag = {int:id_tag}',
@@ -322,18 +321,12 @@ class Tag
 				'id_tag' => $this->id_tag,
 			]);
 
-		$new_id = 0;
-		if(!empty($row))
-		{
-			$new_id = $row->fetch_assoc()['new_id'];
-		}
-
 		return $db->query('
 			UPDATE tags
 			SET id_asset_thumb = {int:new_id}
 			WHERE id_tag = {int:id_tag}',
 			[
-				'new_id' => $new_id,
+				'new_id' => $new_id ?? 0,
 				'id_tag' => $this->id_tag,
 			]);
 	}
-- 
2.46.0


From b8c53d7d4d52d494859554a5c16d3636ef315542 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:45:03 +0100
Subject: [PATCH 50/79] ViewPhotoAlbum: prevent undefined index due to missing
 thumb

---
 controllers/ViewPhotoAlbum.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php
index d45588b..9f7c07e 100644
--- a/controllers/ViewPhotoAlbum.php
+++ b/controllers/ViewPhotoAlbum.php
@@ -137,7 +137,8 @@ class ViewPhotoAlbum extends HTMLController
 				'id_tag' => $album['id_tag'],
 				'caption' => $album['tag'],
 				'link' => BASEURL . '/' . $album['slug'] . '/',
-				'thumbnail' => !empty($album['id_asset_thumb']) ? $assets[$album['id_asset_thumb']]->getImage() : null,
+				'thumbnail' => !empty($album['id_asset_thumb']) && isset($assets[$album['id_asset_thumb']])
+					? $assets[$album['id_asset_thumb']]->getImage() : null,
 			];
 		}
 
-- 
2.46.0


From 4c928af9add0338ac37e5cbc7af455957752f342 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:46:23 +0100
Subject: [PATCH 51/79] AlbumIndex: set thumbnail dimensions for 'no thumb'
 images too

---
 templates/AlbumIndex.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/templates/AlbumIndex.php b/templates/AlbumIndex.php
index 3205b81..d311645 100644
--- a/templates/AlbumIndex.php
+++ b/templates/AlbumIndex.php
@@ -60,7 +60,8 @@ class AlbumIndex extends Template
 				}
 				else
 					echo '
-								<img src="', BASEURL, '/images/nothumb.svg" alt="">';
+								<img src="', BASEURL, '/images/nothumb.svg" alt="" style="width: ',
+									static::TILE_WIDTH, 'px; height: ', static::TILE_HEIGHT, 'px">';
 
 				if ($this->show_labels)
 					echo '
-- 
2.46.0


From 16eda4cfe737af9595d7a4ee288ce2d643d606c4 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:50:08 +0100
Subject: [PATCH 52/79] Move autosuggest styles to default.css

---
 public/css/admin.css   | 24 ------------------------
 public/css/default.css | 24 ++++++++++++++++++++++++
 2 files changed, 24 insertions(+), 24 deletions(-)

diff --git a/public/css/admin.css b/public/css/admin.css
index 0316b80..d56938f 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -12,30 +12,6 @@
 }
 
 
-/* (Tag) autosuggest
-----------------------*/
-#new_tag_container {
-	display: block;
-	position: relative;
-}
-.autosuggest {
-	background: #fff;
-	border: 1px solid #ccc;
-	position: absolute;
-	top: 29px;
-	margin: 0;
-	padding: 0;
-}
-.autosuggest li {
-	display: block !important;
-	padding: 3px;
-}
-.autosuggest li:hover, .autosuggest li.selected {
-	background: #CFECF7;
-	cursor: pointer;
-}
-
-
 /* Edit icon on tiled grids
 -----------------------------*/
 .tiled_grid div.landscape, .tiled_grid div.portrait, .tiled_grid div.panorama {
diff --git a/public/css/default.css b/public/css/default.css
index 1732722..7ebf113 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -341,6 +341,30 @@ i.space-invader.alt-7::before {
 }
 
 
+/* (Tag) autosuggest
+----------------------*/
+#new_tag_container {
+	display: block;
+	position: relative;
+}
+.autosuggest {
+	background: #fff;
+	border: 1px solid #ccc;
+	position: absolute;
+	top: 29px;
+	margin: 0;
+	padding: 0;
+}
+.autosuggest li {
+	display: block !important;
+	padding: 3px;
+}
+.autosuggest li:hover, .autosuggest li.selected {
+	background: #CFECF7;
+	cursor: pointer;
+}
+
+
 /* Error pages
 ----------------*/
 .errormsg p {
-- 
2.46.0


From eb04e87085e6d7e373babedc6db0f87590ee53a0 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:52:44 +0100
Subject: [PATCH 53/79] Change autosuggest padding

---
 public/css/default.css | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 7ebf113..056e886 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -351,13 +351,14 @@ i.space-invader.alt-7::before {
 	background: #fff;
 	border: 1px solid #ccc;
 	position: absolute;
-	top: 29px;
+	left: 2px;
+	top: 37px;
 	margin: 0;
 	padding: 0;
 }
 .autosuggest li {
 	display: block !important;
-	padding: 3px;
+	padding: 3px 8px;
 }
 .autosuggest li:hover, .autosuggest li.selected {
 	background: #CFECF7;
-- 
2.46.0


From d83dd6ea6ede77b5b8a62e39b6b1bf6087854733 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:55:44 +0100
Subject: [PATCH 54/79] Remove more obsolete styling

---
 public/css/admin.css   | 14 --------
 public/css/default.css | 76 ------------------------------------------
 2 files changed, 90 deletions(-)

diff --git a/public/css/admin.css b/public/css/admin.css
index d56938f..92c2736 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -1,17 +1,3 @@
-.admin_box {
-	margin: 0;
-	padding: 20px;
-	background: #fff;
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-	overflow: auto;
-}
-
-.admin_box h2 {
-	font: 700 24px "Open Sans", sans-serif;
-	margin: 0 0 0.2em;
-}
-
-
 /* Edit icon on tiled grids
 -----------------------------*/
 .tiled_grid div.landscape, .tiled_grid div.portrait, .tiled_grid div.panorama {
diff --git a/public/css/default.css b/public/css/default.css
index 056e886..aee4d77 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -426,50 +426,6 @@ footer a {
 }
 
 
-/* Login box styles
----------------------*/
-#login {
-	background: #fff;
-	border: 1px solid #aaa;
-	border-radius: 10px;
-	box-shadow: 2px 2px 4px rgba(0,0,0,0.1);
-	margin: 0 auto;
-	overflow: auto;
-	padding: 15px;
-	width: 300px;
-}
-#login dl *, #login button {
-	font-size: 15px;
-	line-height: 35px;
-}
-#login h3 {
-	font: 700 24px/36px "Open Sans", sans-serif;
-	margin: 0;
-}
-#login dd {
-	width: 96%;
-	margin: 0 0 10px;
-}
-#login input {
-	background: #eee;
-	border: 1px solid #aaa;
-	border-radius: 3px;
-	padding: 4px 5px;
-	width: 100%;
-}
-#login div.alert {
-	line-height: normal;
-	margin: 15px 0;
-}
-#login div.buttonstrip {
-	float: right;
-	padding: 0 0 5px;
-}
-#login button {
-	line-height: 20px;
-}
-
-
 /* Styling for the photo pages
 --------------------------------*/
 #photo_frame {
@@ -565,38 +521,6 @@ a#previous_photo:hover, a#next_photo:hover {
 		max-width: 100% !important;
 	}
 
-	h1#logo {
-		font-size: 42px;
-		float: none;
-		margin: 1em 0 0.5em;
-		text-align: center;
-	}
-	h1#logo:before {
-		float: none;
-		font-size: 58px;
-		margin-right: 8px;
-	}
-
-	ul#nav {
-		float: none;
-		padding: 0;
-		margin: 1em 0;
-		text-align: center;
-		overflow: hidden;
-	}
-
-	ul#nav li, ul#nav li a {
-		display: inline-block;
-		float: none;
-	}
-
-	ul#nav li a {
-		float: none;
-		font-size: 16px;
-		margin-left: 6px;
-		padding: 12px 4px;
-	}
-
 	.album_title_box {
 		margin-left: 0;
 	}
-- 
2.46.0


From 1859a9ea2a7fb916f753046a55fd36707513e259 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 21:57:55 +0100
Subject: [PATCH 55/79] LogInForm: fix smartphone view

---
 templates/LogInForm.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/LogInForm.php b/templates/LogInForm.php
index cafb51e..0e0c96a 100644
--- a/templates/LogInForm.php
+++ b/templates/LogInForm.php
@@ -11,7 +11,7 @@ class LogInForm extends SubTemplate
 	private $redirect_url = '';
 	private $emailaddress = '';
 
-	protected $_class = 'content-box container w-50';
+	protected $_class = 'content-box container col-lg-6';
 
 	public function setRedirectUrl($url)
 	{
-- 
2.46.0


From e916489d007996d66271e13bc73121b2d62c9c0c Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 22:04:02 +0100
Subject: [PATCH 56/79] PhotoPage: only use columns on large displays

---
 templates/PhotoPage.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index 490b2cc..932abc0 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -41,7 +41,7 @@ class PhotoPage extends Template
 
 		echo '
 				<div class="row mt-5">
-					<div class="col-8">
+					<div class="col-lg-8">
 						<div id="sub_photo" class="content-box">
 							<h2 class="entry-title">', $this->photo->getTitle(), '</h2>';
 
@@ -51,7 +51,7 @@ class PhotoPage extends Template
 		echo '
 						</div>
 					</div>
-					<div class="col-4">';
+					<div class="col-lg-4">';
 
 		$this->photoMeta();
 
-- 
2.46.0


From 0b8c614191da2ba191c5c83db741d9fdbb409a40 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 22:07:00 +0100
Subject: [PATCH 57/79] Manage{Assets,Tags}: link user names to edituser

---
 controllers/ManageAssets.php | 5 +++--
 controllers/ManageTags.php   | 5 +++--
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/controllers/ManageAssets.php b/controllers/ManageAssets.php
index 499b3c1..2c2bfdc 100644
--- a/controllers/ManageAssets.php
+++ b/controllers/ManageAssets.php
@@ -71,8 +71,9 @@ class ManageAssets extends HTMLController
 					'parse' => [
 						'type' => 'function',
 						'data' => function($row) {
-							if (!empty($row['first_name']))
-								return $row['first_name'] . ' ' . $row['surname'];
+							if (!empty($row['id_user']))
+								return sprintf('<a href="%s/edituser/?id=%d">%s</a>', BASEURL, $row['id_user'],
+									$row['first_name'] . ' ' . $row['surname']);
 							else
 								return 'n/a';
 						},
diff --git a/controllers/ManageTags.php b/controllers/ManageTags.php
index cb1ffd2..ec12de0 100644
--- a/controllers/ManageTags.php
+++ b/controllers/ManageTags.php
@@ -56,8 +56,9 @@ class ManageTags extends HTMLController
 					'parse' => [
 						'type' => 'function',
 						'data' => function($row) {
-							if (!empty($row['first_name']))
-								return $row['first_name'] . ' ' . $row['surname'];
+							if (!empty($row['id_user']))
+								return sprintf('<a href="%s/edituser/?id=%d">%s</a>', BASEURL, $row['id_user'],
+									$row['first_name'] . ' ' . $row['surname']);
 							else
 								return 'n/a';
 						},
-- 
2.46.0


From c7e4351375e51cfcb5aba803391b71c51fc55dea Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 22:13:55 +0100
Subject: [PATCH 58/79] Change album/tile label font to Coda, too

---
 public/css/default.css | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/public/css/default.css b/public/css/default.css
index aee4d77..3e5b8c7 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -192,7 +192,7 @@ i.space-invader.alt-7::before {
 .tiled_grid div h4 {
 	color: #000;
 	margin: 0;
-	font: 400 18px "Open Sans", sans-serif;
+	font: 400 18px 'Coda', sans-serif;
 	padding: 15px 5px;
 	text-overflow: ellipsis;
 	text-align: center;
-- 
2.46.0


From 2c24a0a7e7018a8dcd6ceef7a72001cbfe4ee63a Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sat, 11 Mar 2023 22:15:17 +0100
Subject: [PATCH 59/79] MainTemplate: open vanity link in new tab

---
 templates/MainTemplate.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/MainTemplate.php b/templates/MainTemplate.php
index d23df69..b9e903b 100644
--- a/templates/MainTemplate.php
+++ b/templates/MainTemplate.php
@@ -74,7 +74,7 @@ class MainTemplate extends Template
 		}
 		else
 			echo '
-				<span class="vanity">Powered by <a href="https://aaronweb.net/projects/kabuki/">Kabuki CMS</a></span>';
+				<span class="vanity">Powered by <a href="https://aaronweb.net/projects/kabuki/" target="_blank">Kabuki CMS</a></span>';
 
 		echo '
 			</footer>
-- 
2.46.0


From 70fcd097cc0b3e1f1f099384d6ae1f358ab595e2 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 00:39:15 +0100
Subject: [PATCH 60/79] EditAsset: remove reference to old admin bar

---
 controllers/EditAsset.php      | 4 ----
 controllers/HTMLController.php | 1 -
 templates/PhotoPage.php        | 2 +-
 3 files changed, 1 insertion(+), 6 deletions(-)

diff --git a/controllers/EditAsset.php b/controllers/EditAsset.php
index 8fc7bf4..a6ca7f9 100644
--- a/controllers/EditAsset.php
+++ b/controllers/EditAsset.php
@@ -94,10 +94,6 @@ class EditAsset extends HTMLController
 		$page = new EditAssetForm($asset, $thumbs);
 		parent::__construct('Edit asset \'' . $asset->getTitle() . '\' (' . $asset->getFilename() . ') - ' . SITE_TITLE);
 		$this->page->adopt($page);
-
-		// Add a view button to the admin bar for photos.
-		if ($asset->isImage())
-			$this->admin_bar->appendItem($asset->getImage()->getPageUrl(), 'View this photo');
 	}
 
 	private function getThumbs(Asset $asset)
diff --git a/controllers/HTMLController.php b/controllers/HTMLController.php
index a5d5236..f141638 100644
--- a/controllers/HTMLController.php
+++ b/controllers/HTMLController.php
@@ -12,7 +12,6 @@
 abstract class HTMLController
 {
 	protected $page;
-	protected $admin_bar;
 
 	public function __construct($title)
 	{
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index 932abc0..b2d663f 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -250,7 +250,7 @@ class PhotoPage extends Template
 		echo '
 				<div id="user_actions_box" class="content-box">
 					<h3>Actions</h3>
-					<a class="btn btn-primary" href="', BASEURL, '/editasset/?id=', $this->photo->getId(), '?confirm_delete">Edit photo</a>
+					<a class="btn btn-primary" href="', BASEURL, '/editasset/?id=', $this->photo->getId(), '">Edit photo</a>
 					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete photo</a>
 				</div>';
 	}
-- 
2.46.0


From 0325a2ec9028fcd607719da46eb8c46518f46b5e Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 00:53:47 +0100
Subject: [PATCH 61/79] EditAssetForm: make form look presentable

---
 templates/EditAssetForm.php | 85 +++++++++++++++++++++----------------
 1 file changed, 49 insertions(+), 36 deletions(-)

diff --git a/templates/EditAssetForm.php b/templates/EditAssetForm.php
index 8bdcdaf..d791dcd 100644
--- a/templates/EditAssetForm.php
+++ b/templates/EditAssetForm.php
@@ -22,9 +22,9 @@ class EditAssetForm extends Template
 		echo '
 			<form id="asset_form" action="" method="post" enctype="multipart/form-data">
 				<div class="content-box">
-					<div style="float: right">
+					<div class="float-end">
 						<a class="btn btn-danger" href="', BASEURL, '/', $this->asset->getSlug(), '?delete_confirmed">Delete asset</a>
-						<input class="btn btn-primary" type="submit" value="Save asset data">
+						<button class="btn btn-primary" type="submit">Save asset data</button>
 					</div>
 					<h2>Edit asset \'', $this->asset->getTitle(), '\' (', $this->asset->getFilename(), ')</h2>
 				</div>';
@@ -63,20 +63,32 @@ class EditAssetForm extends Template
 		echo '
 				<div class="content-box key_info">
 					<h3>Key info</h3>
-					<dl>
-						<dt>Title</dt>
-						<dd><input type="text" name="title" maxlength="255" size="70" value="', $this->asset->getTitle(), '">
 
-						<dt>URL slug</dt>
-						<dd><input type="text" name="slug" maxlength="255" size="70" value="', $this->asset->getSlug(), '">
-
-						<dt>Date captured</dt>
-						<dd><input type="text" name="date_captured" size="30" value="',
+					<div class="row mb-2">
+						<label class="col-form-label col-sm-3">Title (internal):</label>
+						<div class="col-sm">
+							<input class="form-control" type="text" name="title" maxlength="255" size="70" value="', $this->asset->getTitle(), '">
+						</div>
+					</div>
+					<div class="row mb-2">
+						<label class="col-form-label col-sm-3">URL slug:</label>
+						<div class="col-sm">
+							<input class="form-control" type="text" name="slug" maxlength="255" size="70" value="', $this->asset->getSlug(), '">
+						</div>
+					</div>
+					<div class="row mb-2">
+						<label class="col-form-label col-sm-3">Date captured:</label>
+						<div class="col-sm">
+							<input class="form-control" name="date_captured" size="30" value="',
 							$date_captured ? $date_captured->format('Y-m-d H:i:s') : '', '" placeholder="Y-m-d H:i:s">
-
-						<dt>Display priority</dt>
-						<dd><input type="number" name="priority" min="0" max="100" step="1" value="', $this->asset->getPriority(), '">
-					</dl>
+						</div>
+					</div>
+					<div class="row mb-2">
+						<label class="col-form-label col-sm-3">Display priority:</label>
+						<div class="col-sm-3">
+							<input class="form-control" type="number" name="priority" min="0" max="100" step="1" value="', $this->asset->getPriority(), '">
+						</div>
+					</div>
 				</div>';
 	}
 
@@ -85,7 +97,7 @@ class EditAssetForm extends Template
 		echo '
 				<div class="content-box linked_tags">
 					<h3>Linked tags</h3>
-					<ul id="tag_list">';
+					<ul class="list-unstyled" id="tag_list">';
 
 		foreach ($this->asset->getTags() as $tag)
 				echo '
@@ -95,7 +107,7 @@ class EditAssetForm extends Template
 						</li>';
 
 		echo '
-						<li id="new_tag_container"><input type="text" id="new_tag" placeholder="Type to link a new tag"></li>
+						<li id="new_tag_container"><input class="form-control" type="text" id="new_tag" placeholder="Type to link a new tag"></li>
 					</ul>
 				</div>
 				<script type="text/javascript" src="', BASEURL, '/js/ajax.js"></script>
@@ -138,7 +150,7 @@ class EditAssetForm extends Template
 		echo '
 				<div class="content-box linked_thumbs">
 					<h3>Thumbnails</h3>
-					View: <select id="thumbnail_src">';
+					View: <select class="form-select w-auto d-inline" id="thumbnail_src">';
 
 		$first = INF;
 		foreach ($this->thumbs as $i => $thumb)
@@ -220,38 +232,39 @@ class EditAssetForm extends Template
 	protected function section_asset_meta()
 	{
 		echo '
-				<div class="content-box asset_meta" style="margin-top: 2%">
-					<h3>Asset meta data</h3>
-					<ul>';
+				<div class="content-box asset_meta mt-2">
+					<h3>Asset meta data</h3>';
 
-		$i = -1;
+		$i = 0;
 		foreach ($this->asset->getMeta() as $key => $meta)
 		{
-			$i++;
 			echo '
-						<li>
-							<input type="text" name="meta_key[', $i, ']" value="', htmlentities($key), '">
-							<input type="text" name="meta_value[', $i, ']" value="', htmlentities($meta), '">
-						</li>';
+					<div class="input-group">
+						<input type="text" class="form-control" name="meta_key[', $i, ']" value="', htmlspecialchars($key), '" placeholder="key">
+						<input type="text" class="form-control" name="meta_value[', $i, ']" value="', htmlspecialchars($meta), '" placeholder="value">
+					</div>';
+			$i++;
 		}
 
+
 		echo '
-						<li>
-							<input type="text" name="meta_key[', $i + 1, ']" value="">
-							<input type="text" name="meta_value[', $i + 1, ']" value="">
-						</li>
-					</ul>
-					<p><input type="submit" value="Save metadata"></p>
+					<div class="input-group">
+						<input type="text" class="form-control" name="meta_key[', $i + 1, ']" value="" placeholder="key">
+						<input type="text" class="form-control" name="meta_value[', $i + 1, ']" value="" placeholder="value">
+					</div>
+					<div class="text-end mt-3">
+						<button class="btn btn-primary" type="submit">Save metadata</button>
+					</div>
 				</div>';
 	}
 
 	protected function section_replace()
 	{
 		echo '
-				<div class="content-box replace_asset" style="margin-bottom: 2%; display: block">
+				<div class="content-box replace_asset mt-2">
 					<h3>Replace asset</h3>
-					File: <input type="file" name="replacement">
-					Target: <select name="replacement_target">
+					File: <input class="form-control d-inline w-auto" type="file" name="replacement">
+					Target: <select class="form-select d-inline w-auto" name="replacement_target">
 						<option value="full">master file</option>';
 
 		foreach ($this->thumbs as $thumb)
@@ -287,7 +300,7 @@ class EditAssetForm extends Template
 
 		echo '
 					</select>
-					<input type="submit" value="Save asset">
+					<button class="btn btn-primary" type="submit">Save asset</button>
 				</div>';
 	}
 }
-- 
2.46.0


From 01822cdccffc71465e9bcf3fa751c38fd3bd66e5 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 01:00:50 +0100
Subject: [PATCH 62/79] Fix Button, ConfirmDeletePage, WarningDialog templates

---
 controllers/ViewPhoto.php       | 2 +-
 templates/Button.php            | 4 ++--
 templates/ConfirmDeletePage.php | 6 +++---
 templates/PhotoPage.php         | 2 +-
 templates/WarningDialog.php     | 2 +-
 5 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/controllers/ViewPhoto.php b/controllers/ViewPhoto.php
index e46929d..5e29ea8 100644
--- a/controllers/ViewPhoto.php
+++ b/controllers/ViewPhoto.php
@@ -39,7 +39,7 @@ class ViewPhoto extends HTMLController
 			$page = new ConfirmDeletePage($photo->getImage());
 			$this->page->adopt($page);
 		}
-		else if (isset($_REQUEST['delete_confirmed']))
+		elseif (isset($_REQUEST['delete_confirmed']))
 		{
 			$album_url = $photo->getSubdir();
 			$photo->delete();
diff --git a/templates/Button.php b/templates/Button.php
index 29024e1..c978c42 100644
--- a/templates/Button.php
+++ b/templates/Button.php
@@ -6,7 +6,7 @@
  * Kabuki CMS (C) 2013-2015, Aaron van Geffen
  *****************************************************************************/
 
-class Button extends SubTemplate
+class Button extends Template
 {
 	private $content = '';
 	private $href = '';
@@ -19,7 +19,7 @@ class Button extends SubTemplate
 		$this->class = $class;
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		echo '
 					<a class="', $this->class, '" href="', $this->href, '">', $this->content, '</a>';
diff --git a/templates/ConfirmDeletePage.php b/templates/ConfirmDeletePage.php
index bdba97c..80075c5 100644
--- a/templates/ConfirmDeletePage.php
+++ b/templates/ConfirmDeletePage.php
@@ -13,7 +13,7 @@ class ConfirmDeletePage extends PhotoPage
 		parent::__construct($photo);
 	}
 
-	protected function html_content()
+	public function html_main()
 	{
 		$this->confirm();
 		$this->photo();
@@ -22,7 +22,7 @@ class ConfirmDeletePage extends PhotoPage
 	private function confirm()
 	{
 		$buttons = [];
-		$buttons[] = new Button("Delete", BASEURL . '/' . $this->photo->getSlug() . '?delete_confirmed', "btn btn-red");
+		$buttons[] = new Button("Delete", BASEURL . '/' . $this->photo->getSlug() . '/?delete_confirmed', "btn btn-danger");
 		$buttons[] = new Button("Cancel", $this->photo->getPageUrl(), "btn");
 
 		$alert = new WarningDialog(
@@ -30,6 +30,6 @@ class ConfirmDeletePage extends PhotoPage
 			"You are about to permanently delete the following photo.",
 			$buttons
 		);
-		$alert->html_content();
+		$alert->html_main();
 	}
 }
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index b2d663f..49ac58b 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -251,7 +251,7 @@ class PhotoPage extends Template
 				<div id="user_actions_box" class="content-box">
 					<h3>Actions</h3>
 					<a class="btn btn-primary" href="', BASEURL, '/editasset/?id=', $this->photo->getId(), '">Edit photo</a>
-					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '?confirm_delete">Delete photo</a>
+					<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '/?confirm_delete">Delete photo</a>
 				</div>';
 	}
 }
diff --git a/templates/WarningDialog.php b/templates/WarningDialog.php
index 198b7e0..6a9c93e 100644
--- a/templates/WarningDialog.php
+++ b/templates/WarningDialog.php
@@ -24,6 +24,6 @@ class WarningDialog extends Alert
 	private function addButtons()
 	{
 		foreach ($this->buttons as $button)
-			$button->html_content();
+			$button->html_main();
 	}
 }
-- 
2.46.0


From 3cf281b24d357519c0e1fa9cdf553e18d9155f0a Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 01:04:28 +0100
Subject: [PATCH 63/79] AdminMenu: add error count to badge iff count > 0

---
 models/AdminMenu.php | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/models/AdminMenu.php b/models/AdminMenu.php
index aae9e0d..e0515d0 100644
--- a/models/AdminMenu.php
+++ b/models/AdminMenu.php
@@ -15,9 +15,10 @@ class AdminMenu extends Menu
 		if (!$user->isAdmin())
 			return;
 
-		$this->items[] = [
+		$this->items[0] = [
 			'label' => 'Admin',
 			'icon' => 'gear',
+			'badge' => ErrorLog::getCount(),
 			'subs' => [
 				[
 					'uri' => '/managealbums/',
@@ -43,6 +44,9 @@ class AdminMenu extends Menu
 			],
 		];
 
+		if ($this->items[0]['badge'] == 0)
+			unset($this->items[0]['badge']);
+
 		foreach ($this->items as $i => $item)
 		{
 			if (isset($item['uri']))
-- 
2.46.0


From 6087ebe2495615e8164a56a924446320d2f2ee5d Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 01:19:43 +0100
Subject: [PATCH 64/79] AutoSuggest: fix click/append event

Keyboard was fine, it was just mouse events that were broken ^^'
---
 public/js/autosuggest.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/public/js/autosuggest.js b/public/js/autosuggest.js
index 76ab820..e1bcb42 100644
--- a/public/js/autosuggest.js
+++ b/public/js/autosuggest.js
@@ -124,7 +124,7 @@ class AutoSuggest {
             node.innerHTML = this.highlightMatches(query_tokens, item.label);
             node.jsondata = item;
             node.addEventListener('click', event => {
-                this.appendCallback(event.target.jsondata);
+                this.appendCallback(node.jsondata);
                 this.closeContainer();
                 this.clearInput();
             });
-- 
2.46.0


From 544944a7f5073aad3a2422eba0940efffe7af173 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 11:32:13 +0100
Subject: [PATCH 65/79] Edit{Album,Tag}: fix new tag creation

---
 controllers/EditAlbum.php | 2 +-
 controllers/EditTag.php   | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/controllers/EditAlbum.php b/controllers/EditAlbum.php
index 6d73428..9ac4e23 100644
--- a/controllers/EditAlbum.php
+++ b/controllers/EditAlbum.php
@@ -152,7 +152,7 @@ class EditAlbum extends HTMLController
 		if (isset($_POST['changeThumbnail']))
 			$this->processThumbnail($album);
 		elseif (!empty($_POST))
-			$this->processTagDetails($form, $id_tag, $album);
+			$this->processTagDetails($form, $id_tag, $album ?? null);
 	}
 
 	private function processThumbnail($tag)
diff --git a/controllers/EditTag.php b/controllers/EditTag.php
index c96ef0e..364a6f4 100644
--- a/controllers/EditTag.php
+++ b/controllers/EditTag.php
@@ -130,7 +130,7 @@ class EditTag extends HTMLController
 		if (isset($_POST['changeThumbnail']))
 			$this->processThumbnail($tag);
 		elseif (!empty($_POST))
-			$this->processTagDetails($form, $id_tag, $tag);
+			$this->processTagDetails($form, $id_tag, $tag ?? null);
 	}
 
 	private function processThumbnail($tag)
-- 
2.46.0


From 54b69ecd11554d5aaaf1b302fe1774ee5395db33 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 11:33:16 +0100
Subject: [PATCH 66/79] MediaUploader: simplify form control design

---
 templates/MediaUploader.php | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/templates/MediaUploader.php b/templates/MediaUploader.php
index d577ec7..69aa083 100644
--- a/templates/MediaUploader.php
+++ b/templates/MediaUploader.php
@@ -20,12 +20,9 @@ class MediaUploader extends SubTemplate
 		echo '
 			<form action="', BASEURL, '/uploadmedia/?tag=', $this->tag->id_tag, '" method="post" enctype="multipart/form-data">
 				<h2>Upload new photos to &quot;', $this->tag->tag, '&quot;</h2>
-				<div>
-					<h3>Select files</h3>
-					<input class="form-control" type="file" id="upload_queue" name="uploads[]" multiple>
-				</div>
-				<div>
-					<input class="btn btn-primary" name="save" id="photo_submit" type="submit" value="Upload the lot">
+				<div class="input-group">
+					<input class="form-control d-inline" type="file" id="upload_queue" name="uploads[]" multiple>
+					<button class="btn btn-primary" name="save" id="photo_submit" type="submit">Upload the lot</button>
 				</div>
 				<div id="upload_preview_area">
 				</div>
-- 
2.46.0


From 229fb9e5bfc1bf863b6f3b8ac262ee13267f3f4b Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 11:45:37 +0100
Subject: [PATCH 67/79] UploadQueue: refactor into proper ECMAScript class

---
 public/js/upload_queue.js | 382 +++++++++++++++++++-------------------
 1 file changed, 189 insertions(+), 193 deletions(-)

diff --git a/public/js/upload_queue.js b/public/js/upload_queue.js
index a0953ff..54384f6 100644
--- a/public/js/upload_queue.js
+++ b/public/js/upload_queue.js
@@ -1,215 +1,211 @@
-function UploadQueue(options) {
-	this.queue = options.queue_element;
-	this.preview_area = options.preview_area;
-	this.upload_progress = [];
-	this.upload_url = options.upload_url;
-	this.submit = options.submit_button;
-	this.addEvents();
-}
+class UploadQueue {
+	constructor(options) {
+		this.queue = options.queue_element;
+		this.preview_area = options.preview_area;
+		this.upload_progress = [];
+		this.upload_url = options.upload_url;
+		this.submit = options.submit_button;
+		this.addEvents();
+	}
 
-UploadQueue.prototype.addEvents = function() {
-	var that = this;
-	that.queue.addEventListener('change', function() {
-		that.showSpinner(that.queue, "Generating previews (not uploading yet!)");
-		that.clearPreviews();
-		for (var i = 0; i < that.queue.files.length; i++) {
-			var callback = (i !== that.queue.files.length - 1) ? null : function() {
-				that.hideSpinner();
-				that.submit.disabled = false;
+	addEvents() {
+		this.queue.addEventListener('change', event => {
+			this.showSpinner(this.queue, "Generating previews (not uploading yet!)");
+			this.clearPreviews();
+			for (let i = 0; i < this.queue.files.length; i++) {
+				const callback = (i !== this.queue.files.length - 1) ? null : () => {
+					this.hideSpinner();
+					this.submit.disabled = false;
+				};
+
+				if (this.queue.files[0].name.includes(".HEIC")) {
+					alert('Sorry, the HEIC image format is not supported.\nPlease convert your photos to JPEG before uploading.');
+					this.hideSpinner();
+					this.submit.disabled = false;
+					break;
+				}
+
+				this.addPreviewBoxForQueueSlot(i);
+				this.addPreviewForFile(this.queue.files[i], i, callback);
 			};
-
-			if (that.queue.files[0].name.includes(".HEIC")) {
-				alert('Sorry, the HEIC image format is not supported.\nPlease convert your photos to JPEG before uploading.');
-				that.hideSpinner();
-				that.submit.disabled = false;
-				break;
-			}
-
-			that.addPreviewBoxForQueueSlot(i);
-			that.addPreviewForFile(that.queue.files[i], i, callback);
-		};
-	});
-	that.submit.addEventListener('click', function(e) {
-		e.preventDefault();
-		that.process();
-	});
-	this.submit.disabled = true;
-};
-
-UploadQueue.prototype.clearPreviews = function() {
-	this.preview_area.innerHTML = '';
-	this.submit.disabled = true;
-	this.current_upload_index = -1;
-}
-
-UploadQueue.prototype.addPreviewBoxForQueueSlot = function(index) {
-	var preview_box = document.createElement('div');
-	preview_box.id = 'upload_preview_' + index;
-	this.preview_area.appendChild(preview_box);
-};
-
-UploadQueue.prototype.addPreviewForFile = function(file, index, callback) {
-	if (!file) {
-		return false;
-	}
-
-	var preview = document.createElement('canvas');
-	preview.title = file.name;
-
-	var preview_box = document.getElementById('upload_preview_' + index);
-	preview_box.appendChild(preview);
-
-	var reader = new FileReader();
-	var that = this;
-	reader.addEventListener('load', function() {
-		var original = document.createElement('img');
-		original.src = reader.result;
-
-		original.addEventListener('load', function() {
-			// Preparation: make canvas size proportional to the original image.
-			preview.height = 150;
-			preview.width = preview.height * (original.width / original.height);
-
-			// First pass: resize to 50% on temp canvas.
-			var temp = document.createElement('canvas'),
-				tempCtx = temp.getContext('2d');
-
-			temp.width = original.width * 0.5;
-			temp.height = original.height * 0.5;
-			tempCtx.drawImage(original, 0, 0, temp.width, temp.height);
-
-			// Second pass: resize again on temp canvas.
-			tempCtx.drawImage(temp, 0, 0, temp.width * 0.5, temp.height * 0.5);
-
-			// Final pass: resize to desired size on preview canvas.
-			var context = preview.getContext('2d');
-			context.drawImage(temp, 0, 0, temp.width * 0.5, temp.height * 0.5,
-				0, 0, preview.width, preview.height);
-
-			if (callback) {
-				callback();
-			}
 		});
-	}, false);
-	reader.readAsDataURL(file);
-};
-
-UploadQueue.prototype.process = function() {
-	this.showSpinner(this.submit, "Preparing to upload files...");
-	if (this.queue.files.length > 0) {
+		this.submit.addEventListener('click', event => {
+			event.preventDefault();
+			this.process();
+		});
 		this.submit.disabled = true;
-		this.nextFile();
 	}
-};
 
-UploadQueue.prototype.nextFile = function() {
-	var files = this.queue.files;
-	var i = ++this.current_upload_index;
-	if (i === files.length) {
-		this.hideSpinner();
-	} else {
-		this.setSpinnerLabel("Uploading file " + (i + 1) + " out of " + files.length);
-		this.sendFile(files[i], i, function() {
+	clearPreviews() {
+		this.preview_area.innerHTML = '';
+		this.submit.disabled = true;
+		this.current_upload_index = -1;
+	}
+
+	addPreviewBoxForQueueSlot(index) {
+		const preview_box = document.createElement('div');
+		preview_box.id = 'upload_preview_' + index;
+		this.preview_area.appendChild(preview_box);
+	}
+
+	addPreviewForFile(file, index, callback) {
+		if (!file) {
+			return false;
+		}
+
+		const preview = document.createElement('canvas');
+		preview.title = file.name;
+
+		const preview_box = document.getElementById('upload_preview_' + index);
+		preview_box.appendChild(preview);
+
+		const reader = new FileReader();
+		reader.addEventListener('load', event => {
+			const original = document.createElement('img');
+			original.src = reader.result;
+
+			original.addEventListener('load', function() {
+				// Preparation: make canvas size proportional to the original image.
+				preview.height = 150;
+				preview.width = preview.height * (original.width / original.height);
+
+				// First pass: resize to 50% on temp canvas.
+				const temp = document.createElement('canvas'),
+					tempCtx = temp.getContext('2d');
+
+				temp.width = original.width * 0.5;
+				temp.height = original.height * 0.5;
+				tempCtx.drawImage(original, 0, 0, temp.width, temp.height);
+
+				// Second pass: resize again on temp canvas.
+				tempCtx.drawImage(temp, 0, 0, temp.width * 0.5, temp.height * 0.5);
+
+				// Final pass: resize to desired size on preview canvas.
+				const context = preview.getContext('2d');
+				context.drawImage(temp, 0, 0, temp.width * 0.5, temp.height * 0.5,
+					0, 0, preview.width, preview.height);
+
+				if (callback) {
+					callback();
+				}
+			});
+		}, false);
+		reader.readAsDataURL(file);
+	}
+
+	process() {
+		this.showSpinner(this.submit, "Preparing to upload files...");
+		if (this.queue.files.length > 0) {
+			this.submit.disabled = true;
 			this.nextFile();
+		}
+	}
+
+	nextFile() {
+		const files = this.queue.files;
+		const i = ++this.current_upload_index;
+		if (i === files.length) {
+			this.hideSpinner();
+		} else {
+			this.setSpinnerLabel("Uploading file " + (i + 1) + " out of " + files.length);
+			this.sendFile(files[i], i, this.nextFile);
+		}
+	}
+
+	sendFile(file, index, callback) {
+		const request = new XMLHttpRequest();
+		request.addEventListener('error', event => {
+			this.updateProgress(index, -1);
 		});
-	}
-};
-
-UploadQueue.prototype.sendFile = function(file, index, callback) {
-	// Prepare the request.
-	var that = this;
-	var request = new XMLHttpRequest();
-	request.addEventListener('error', function(event) {
-		that.updateProgress(index, -1);
-	});
-	request.addEventListener('progress', function(event) {
-		that.updateProgress(index, event.loaded / event.total);
-	});
-	request.addEventListener('load', function(event) {
-		that.updateProgress(index, 1);
-		if (request.responseText !== null && request.status === 200) {
-			var obj = JSON.parse(request.responseText);
-			if (obj.error) {
-				alert(obj.error);
-				return;
+		request.addEventListener('progress', event => {
+			this.updateProgress(index, event.loaded / event.total);
+		});
+		request.addEventListener('load', event => {
+			this.updateProgress(index, 1);
+			if (request.responseText !== null && request.status === 200) {
+				const obj = JSON.parse(request.responseText);
+				if (obj.error) {
+					alert(obj.error);
+					return;
+				}
+				else if (callback) {
+					callback.call(this, obj);
+				}
 			}
-			else if (callback) {
-				callback.call(that, obj);
+		});
+
+		const data = new FormData();
+		data.append('uploads', file, file.name);
+
+		request.open('POST', this.upload_url, true);
+		request.send(data);
+	}
+
+	addProgressBar(index) {
+		if (index in this.upload_progress) {
+			return;
+		}
+
+		const progress_container = document.createElement('div');
+		progress_container.className = 'progress';
+
+		const progress = document.createElement('div');
+		progress_container.appendChild(progress);
+
+		const preview_box = document.getElementById('upload_preview_' + index);
+		preview_box.appendChild(progress_container);
+
+		this.upload_progress[index]	= progress;
+	}
+
+	updateProgress(index, progress) {
+		if (!(index in this.upload_progress)) {
+			this.addProgressBar(index);
+		}
+
+		const bar = this.upload_progress[index];
+
+		if (progress >= 0) {
+			bar.style.width = Math.ceil(progress * 100) + '%';
+		} else {
+			bar.style.width = "";
+			if (progress === -1) {
+				bar.className = "error";
 			}
 		}
-	});
-
-	var data = new FormData();
-	data.append('uploads', file, file.name);
-
-	request.open('POST', this.upload_url, true);
-	request.send(data);
-};
-
-UploadQueue.prototype.addProgressBar = function(index) {
-	if (index in this.upload_progress) {
-		return;
 	}
 
-	var progress_container = document.createElement('div');
-	progress_container.className = 'progress';
+	showSpinner(sibling, label) {
+		if (this.spinner) {
+			return;
+		}
 
-	var progress = document.createElement('div');
-	progress_container.appendChild(progress);
+		this.spinner = document.createElement('div');
+		this.spinner.className = 'spinner';
+		sibling.parentNode.appendChild(this.spinner);
 
-	var preview_box = document.getElementById('upload_preview_' + index);
-	preview_box.appendChild(progress_container);
-
-	this.upload_progress[index]	= progress;
-};
-
-UploadQueue.prototype.updateProgress = function(index, progress) {
-	if (!(index in this.upload_progress)) {
-		this.addProgressBar(index);
-	}
-
-	var bar = this.upload_progress[index];
-
-	if (progress >= 0) {
-		bar.style.width = Math.ceil(progress * 100) + '%';
-	} else {
-		bar.style.width = "";
-		if (progress === -1) {
-			bar.className = "error";
+		if (label) {
+			this.spinner_label = document.createElement('span');
+			this.spinner_label.className = 'spinner_label';
+			this.spinner_label.innerHTML = label;
+			sibling.parentNode.appendChild(this.spinner_label);
 		}
 	}
-};
 
-UploadQueue.prototype.showSpinner = function(sibling, label) {
-	if (this.spinner) {
-		return;
+	setSpinnerLabel(label) {
+		if (this.spinner_label) {
+			this.spinner_label.innerHTML = label;
+		}
 	}
 
-	this.spinner = document.createElement('div');
-	this.spinner.className = 'spinner';
-	sibling.parentNode.appendChild(this.spinner);
-
-	if (label) {
-		this.spinner_label = document.createElement('span');
-		this.spinner_label.className = 'spinner_label';
-		this.spinner_label.innerHTML = label;
-		sibling.parentNode.appendChild(this.spinner_label);
-	}
-};
-
-UploadQueue.prototype.setSpinnerLabel = function(label) {
-	if (this.spinner_label) {
-		this.spinner_label.innerHTML = label;
+	hideSpinner() {
+		if (this.spinner) {
+			this.spinner.parentNode.removeChild(this.spinner);
+			this.spinner = null;
+		}
+		if (this.spinner_label) {
+			this.spinner_label.parentNode.removeChild(this.spinner_label);
+			this.spinner_label = null;
+		}
 	}
 }
-
-UploadQueue.prototype.hideSpinner = function() {
-	if (this.spinner) {
-		this.spinner.parentNode.removeChild(this.spinner);
-		this.spinner = null;
-	}
-	if (this.spinner_label) {
-		this.spinner_label.parentNode.removeChild(this.spinner_label);
-		this.spinner_label = null;
-	}
-};
-- 
2.46.0


From 3ed84eb4d5a8e0702a3204e165e12209ce492f77 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 11:47:36 +0100
Subject: [PATCH 68/79] UploadQueue: more correct HEIC extension check

---
 public/js/upload_queue.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/public/js/upload_queue.js b/public/js/upload_queue.js
index 54384f6..506a64b 100644
--- a/public/js/upload_queue.js
+++ b/public/js/upload_queue.js
@@ -18,7 +18,7 @@ class UploadQueue {
 					this.submit.disabled = false;
 				};
 
-				if (this.queue.files[0].name.includes(".HEIC")) {
+				if (this.queue.files[0].name.toUpperCase().endsWith(".HEIC")) {
 					alert('Sorry, the HEIC image format is not supported.\nPlease convert your photos to JPEG before uploading.');
 					this.hideSpinner();
 					this.submit.disabled = false;
-- 
2.46.0


From 244af88a9a9636ba92edc49d190fdb9c0710ac40 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:02:21 +0100
Subject: [PATCH 69/79] Asset: cleaner handling of conflicting filenames

---
 controllers/UploadMedia.php |  4 ----
 models/Asset.php            | 12 ++++++++----
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/controllers/UploadMedia.php b/controllers/UploadMedia.php
index 571047b..1457131 100644
--- a/controllers/UploadMedia.php
+++ b/controllers/UploadMedia.php
@@ -33,14 +33,10 @@ class UploadMedia extends HTMLController
 				if (empty($uploaded_file))
 					continue;
 
-				// DIY slug club.
-				$slug = $tag->slug . '/' . strtr($uploaded_file['name'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '\\' => '-']);
-
 				$asset = Asset::createNew([
 					'filename_to_copy' => $uploaded_file['tmp_name'],
 					'preferred_filename' => $uploaded_file['name'],
 					'preferred_subdir' => $tag->slug,
-					'slug' => $slug,
 				]);
 
 				$new_ids[] = $asset->getId();
diff --git a/models/Asset.php b/models/Asset.php
index bdf6b38..8b31c0f 100644
--- a/models/Asset.php
+++ b/models/Asset.php
@@ -187,9 +187,10 @@ class Asset
 
 		$new_filename = $preferred_filename;
 		$destination = ASSETSDIR . '/' . $preferred_subdir . '/' . $preferred_filename;
-		while (file_exists($destination))
+		for ($i = 1; file_exists($destination); $i++)
 		{
-			$filename = pathinfo($preferred_filename, PATHINFO_FILENAME) . '_' . mt_rand(10, 99);
+			$suffix = $i;
+			$filename = pathinfo($preferred_filename, PATHINFO_FILENAME) . ' (' . $suffix . ')';
 			$extension = pathinfo($preferred_filename, PATHINFO_EXTENSION);
 			$new_filename = $filename . '.' . $extension;
 			$destination = dirname($destination) . '/' . $new_filename;
@@ -206,11 +207,14 @@ class Asset
 		$mimetype = finfo_file($finfo, $destination);
 		finfo_close($finfo);
 
+		// We're going to need the base name a few times...
+		$basename = pathinfo($new_filename, PATHINFO_FILENAME);
+
 		// Do we have a title yet? Otherwise, use the filename.
-		$title = isset($data['title']) ? $data['title'] : pathinfo($preferred_filename, PATHINFO_FILENAME);
+		$title = $data['title'] ?? $basename;
 
 		// Same with the slug.
-		$slug = isset($data['slug']) ? $data['slug'] : $preferred_subdir . '/' . pathinfo($preferred_filename, PATHINFO_FILENAME);
+		$slug = $data['slug'] ?? sprintf('%s/%s', $preferred_subdir, $basename);
 
 		// Detected an image?
 		if (substr($mimetype, 0, 5) == 'image')
-- 
2.46.0


From 3f66fce2626a7864d43fb4eef171f010271c85a4 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:07:17 +0100
Subject: [PATCH 70/79] MediaUploader: explicitly support image/jpeg only

---
 templates/MediaUploader.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/templates/MediaUploader.php b/templates/MediaUploader.php
index 69aa083..1a6aab4 100644
--- a/templates/MediaUploader.php
+++ b/templates/MediaUploader.php
@@ -21,7 +21,8 @@ class MediaUploader extends SubTemplate
 			<form action="', BASEURL, '/uploadmedia/?tag=', $this->tag->id_tag, '" method="post" enctype="multipart/form-data">
 				<h2>Upload new photos to &quot;', $this->tag->tag, '&quot;</h2>
 				<div class="input-group">
-					<input class="form-control d-inline" type="file" id="upload_queue" name="uploads[]" multiple>
+					<input class="form-control d-inline" type="file" id="upload_queue" name="uploads[]"
+						accept="image/jpeg" multiple>
 					<button class="btn btn-primary" name="save" id="photo_submit" type="submit">Upload the lot</button>
 				</div>
 				<div id="upload_preview_area">
-- 
2.46.0


From 29bf6af1f81d940978925c04b2f2bbba23e33cdf Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:21:43 +0100
Subject: [PATCH 71/79] Asset: delete thumbnails when deleting an assets

---
 models/Asset.php | 37 ++++++++++++++++++++++++++++++++-----
 1 file changed, 32 insertions(+), 5 deletions(-)

diff --git a/models/Asset.php b/models/Asset.php
index 8b31c0f..f107b4b 100644
--- a/models/Asset.php
+++ b/models/Asset.php
@@ -490,9 +490,7 @@ class Asset
 	{
 		$db = Registry::get('db');
 
-		if (!unlink(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename))
-			return false;
-
+		// First: delete associated metadata
 		$db->query('
 			DELETE FROM assets_meta
 			WHERE id_asset = {int:id_asset}',
@@ -500,6 +498,7 @@ class Asset
 				'id_asset' => $this->id_asset,
 			]);
 
+		// Second: figure out what tags to recount cardinality for
 		$recount_tags = $db->queryValues('
 			SELECT id_tag
 			FROM assets_tags
@@ -517,13 +516,30 @@ class Asset
 
 		Tag::recount($recount_tags);
 
-		$return = $db->query('
-			DELETE FROM assets
+		// Third: figure out what associated thumbs to delete
+		$thumbs_to_delete = $db->queryValues('
+			SELECT filename
+			FROM assets_thumbs
 			WHERE id_asset = {int:id_asset}',
 			[
 				'id_asset' => $this->id_asset,
 			]);
 
+		foreach ($thumbs_to_delete as $filename)
+		{
+			$thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $filename;
+			if (is_file($thumb_path))
+				unlink($thumb_path);
+		}
+
+		$db->query('
+			DELETE FROM assets_thumbs
+			WHERE id_asset = {int:id_asset}',
+			[
+				'id_asset' => $this->id_asset,
+			]);
+
+		// Reset asset ID for tags that use this asset for their thumbnail
 		$rows = $db->query('
 			SELECT id_tag
 			FROM tags
@@ -541,6 +557,17 @@ class Asset
 			}
 		}
 
+		// Finally, delete the actual asset
+		if (!unlink(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename))
+			return false;
+
+		$return = $db->query('
+			DELETE FROM assets
+			WHERE id_asset = {int:id_asset}',
+			[
+				'id_asset' => $this->id_asset,
+			]);
+
 		return $return;
 	}
 
-- 
2.46.0


From 41881594e94f8390fbdbeb22066921cb256b74bb Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:34:47 +0100
Subject: [PATCH 72/79] PhotoMosaic: make photo order more intuitive

---
 models/PhotoMosaic.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/models/PhotoMosaic.php b/models/PhotoMosaic.php
index 605bdb0..db86be7 100644
--- a/models/PhotoMosaic.php
+++ b/models/PhotoMosaic.php
@@ -79,7 +79,7 @@ class PhotoMosaic
 			return -$priority_diff;
 
 		// In other cases, we'll just show the newest first.
-		return $a->getDateCaptured() > $b->getDateCaptured() ? -1 : 1;
+		return $a->getDateCaptured() <=> $b->getDateCaptured();
 	}
 
 	private static function daysApart(Image $a, Image $b)
-- 
2.46.0


From c73564846890873f8f6866b4924194e35fcfb0d1 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:37:57 +0100
Subject: [PATCH 73/79] ViewPhoto: improve image alignment in page

---
 public/css/default.css | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index 3e5b8c7..ee6e69f 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -429,6 +429,7 @@ footer a {
 /* Styling for the photo pages
 --------------------------------*/
 #photo_frame {
+	padding-top: 1.5vh;
 	text-align: center;
 }
 #photo_frame a {
@@ -439,8 +440,8 @@ footer a {
 #photo_frame a img {
 	border: none;
 	display: block;
-	height: auto;
-	width: 100%;
+	height: 97vh;
+	width: auto;
 }
 
 #previous_photo, #next_photo {
-- 
2.46.0


From 85be093a3625ff5e368b1223e8885c4bfe429f2f Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:42:43 +0100
Subject: [PATCH 74/79] ViewPhoto: improve vertical alignment of prev/next
 buttons

---
 public/css/default.css | 11 +++--------
 1 file changed, 3 insertions(+), 8 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index ee6e69f..d8294af 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -448,17 +448,12 @@ footer a {
 	background: #fff;
 	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
 	color: #262626;
-	font-size: 3em;
+	font-size: 3rem;
 	line-height: 0.5;
-	padding: 32px 8px;
+	padding: 2rem 0.5rem;
 	position: fixed;
 	text-decoration: none;
-	top: 45%;
-}
-#previous_photo em, #next_photo em {
-	position: absolute;
-	top: -1000em;
-	left: -1000em;
+	top: calc(50% - 5rem);
 }
 span#previous_photo, span#next_photo {
 	opacity: 0.25;
-- 
2.46.0


From 5c2eff09b85a10cff26eb298d1524381940e2732 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:55:32 +0100
Subject: [PATCH 75/79] PhotoPage: apply #photo_frame anchor to clicks as well

---
 public/js/photonav.js   | 4 ++--
 templates/PhotoPage.php | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/public/js/photonav.js b/public/js/photonav.js
index e9019f2..8d43dac 100644
--- a/public/js/photonav.js
+++ b/public/js/photonav.js
@@ -4,14 +4,14 @@ function enableKeyDownNavigation() {
 			var target = document.getElementById("previous_photo").href;
 			if (target) {
 				event.preventDefault();
-				document.location.href = target + '#photo_frame';
+				document.location.href = target;
 			}
 		}
 		else if (event.keyCode == 39) {
 			var target = document.getElementById("next_photo").href;
 			if (target) {
 				event.preventDefault();
-				document.location.href = target + '#photo_frame';
+				document.location.href = target;
 			}
 		}
 	}, false);
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
index 49ac58b..52b6fe5 100644
--- a/templates/PhotoPage.php
+++ b/templates/PhotoPage.php
@@ -84,14 +84,14 @@ class PhotoPage extends Template
 	{
 		if ($this->previous_photo_url)
 			echo '
-				<a href="', $this->previous_photo_url, '" id="previous_photo"><i class="bi bi-arrow-left"></i></a>';
+				<a href="', $this->previous_photo_url, '#photo_frame" id="previous_photo"><i class="bi bi-arrow-left"></i></a>';
 		else
 			echo '
 				<span id="previous_photo"><i class="bi bi-arrow-left"></i></span>';
 
 		if ($this->next_photo_url)
 			echo '
-				<a href="', $this->next_photo_url, '" id="next_photo"><i class="bi bi-arrow-right"></i></a>';
+				<a href="', $this->next_photo_url, '#photo_frame" id="next_photo"><i class="bi bi-arrow-right"></i></a>';
 		else
 			echo '
 				<span id="next_photo"><i class="bi bi-arrow-right"></i></span>';
-- 
2.46.0


From c991f05dd354f99d365893769740fc4610705a89 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Sun, 12 Mar 2023 12:58:58 +0100
Subject: [PATCH 76/79] ViewPhoto: rework solution to work for panoramas, too

---
 public/css/default.css | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/public/css/default.css b/public/css/default.css
index d8294af..3aabe66 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -433,14 +433,16 @@ footer a {
 	text-align: center;
 }
 #photo_frame a {
-	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
-	cursor: -moz-zoom-in;
-	display: inline-block;
+
 }
 #photo_frame a img {
 	border: none;
-	display: block;
+	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	cursor: -moz-zoom-in;
+	display: inline-block;
 	height: 97vh;
+	max-width: 100%;
+	object-fit: contain;
 	width: auto;
 }
 
-- 
2.46.0


From e48f065c2540691a30b251cff0c3d9769dda7612 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Mon, 13 Mar 2023 01:33:10 +0100
Subject: [PATCH 77/79] PhotoIndex: fix inadvertent thumb stretching in rare
 cases

---
 public/css/default.css | 1 +
 1 file changed, 1 insertion(+)

diff --git a/public/css/default.css b/public/css/default.css
index 3aabe66..93e7c58 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -187,6 +187,7 @@ i.space-invader.alt-7::before {
 .tiled_grid div img {
 	background: url('../images/nothumb.svg') center no-repeat;
 	border: none;
+	object-fit: cover;
 	width: 100%;
 }
 .tiled_grid div h4 {
-- 
2.46.0


From c6dc6bbac41d7166ca70def7b994b1ad38dc6563 Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Mon, 13 Mar 2023 01:37:31 +0100
Subject: [PATCH 78/79] AlbumIndex: don't over-fit placeholder images

---
 templates/AlbumIndex.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/AlbumIndex.php b/templates/AlbumIndex.php
index d311645..ebfe9c1 100644
--- a/templates/AlbumIndex.php
+++ b/templates/AlbumIndex.php
@@ -61,7 +61,7 @@ class AlbumIndex extends Template
 				else
 					echo '
 								<img src="', BASEURL, '/images/nothumb.svg" alt="" style="width: ',
-									static::TILE_WIDTH, 'px; height: ', static::TILE_HEIGHT, 'px">';
+									static::TILE_WIDTH, 'px; height: ', static::TILE_HEIGHT, 'px; object-fit: unset">';
 
 				if ($this->show_labels)
 					echo '
-- 
2.46.0


From 65cea8ed8ac98eb6132c7e5bb40e66aea3c7ef8c Mon Sep 17 00:00:00 2001
From: Aaron van Geffen <aaron@aaronweb.net>
Date: Mon, 13 Mar 2023 16:30:24 +0100
Subject: [PATCH 79/79] UploadMedia: only set thumb asset id for tags that
 don't have one yet

---
 controllers/UploadMedia.php | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/controllers/UploadMedia.php b/controllers/UploadMedia.php
index 1457131..194cb9e 100644
--- a/controllers/UploadMedia.php
+++ b/controllers/UploadMedia.php
@@ -42,8 +42,11 @@ class UploadMedia extends HTMLController
 				$new_ids[] = $asset->getId();
 				$asset->linkTags([$tag->id_tag]);
 
-				$tag->id_asset_thumb = $asset->getId();
-				$tag->save();
+				if (empty($tag->id_asset_thumb))
+				{
+					$tag->id_asset_thumb = $asset->getId();
+					$tag->save();
+				}
 			}
 
 			if (isset($_REQUEST['format']) && $_REQUEST['format'] === 'json')
-- 
2.46.0