Browse Source

Refactor frontend JavaScript utilities and integrate Vite for asset management

- Moved AJAX utility functions to a shared OMGUtils.js file for better modularity.
- Updated the editable table functionality to utilize the new AJAX request structure.
- Enhanced the common.js file to include necessary DataTables and Bootstrap dependencies.
- Improved frontend.js to streamline notification handling and user interactions.
- Updated login and masterpage templates for better accessibility and styling.
- Introduced Vite configuration for both frontend and admin assets to improve build process.
- Added a script to generate binary configuration from INI files for enhanced security.
- Implemented a new ApplicationComposition class for better plugin management and loading.
- Added jQuery as a shared module to ensure consistent usage across the application.
softword 2 months ago
parent
commit
2b85d16e22
48 changed files with 1599 additions and 1060 deletions
  1. 2 0
      .gitignore
  2. 3 15
      cfg/config.ini
  3. 19 0
      composer.json
  4. 66 0
      composer.lock
  5. 5 8
      index.php
  6. 23 29
      package.json
  7. 36 0
      scripts/generate-config-bin.sh
  8. 161 0
      scripts/vite-template-plugin.mjs
  9. 5 0
      src/Modules/Blank/Blank.php
  10. 21 31
      src/Modules/Blank/Pages.php
  11. 6 9
      src/OpenMind/Admin/Admin.php
  12. 112 181
      src/OpenMind/Admin/Pages.php
  13. 109 0
      src/OpenMind/System/ApplicationComposition.php
  14. 39 33
      src/OpenMind/System/ErrorHandlers.php
  15. 41 27
      src/OpenMind/System/Notifications.php
  16. 50 57
      src/OpenMind/System/OMUtils.php
  17. 52 20
      src/OpenMind/System/PluginStrategy.php
  18. 116 57
      src/OpenMind/System/System.php
  19. 55 17
      src/OpenMind/System/User.php
  20. 30 27
      src/OpenMind/System/UserRoles.php
  21. 23 14
      src/OpenMind/Web/Controller.php
  22. 4 5
      src/OpenMind/Web/Login.php
  23. 10 1
      src/OpenMind/Web/Web.php
  24. 12 12
      ui/admin/assets/css/style.css
  25. 1 53
      ui/admin/assets/js/OMGUtils.js
  26. 91 97
      ui/admin/assets/js/admin.js
  27. 10 10
      ui/admin/assets/js/index.js
  28. 8 18
      ui/admin/masterpage_template.tpl
  29. 4 4
      ui/admin/omlsettings.tpl
  30. 16 25
      ui/admin/package.json
  31. 5 7
      ui/admin/users.tpl
  32. 32 0
      ui/admin/vite.config.js
  33. 33 29
      ui/admin/webpack.config.js
  34. 2 2
      ui/frontend/bind.tpl
  35. 8 6
      ui/frontend/css/edittable.scss
  36. 14 6
      ui/frontend/errors_template.tpl
  37. 4 2
      ui/frontend/footer.tpl
  38. 5 5
      ui/frontend/header.tpl
  39. 1 53
      ui/frontend/js/OMGUtils.js
  40. 10 3
      ui/frontend/js/common.js
  41. 140 110
      ui/frontend/js/edittable.js
  42. 104 74
      ui/frontend/js/frontend.js
  43. 3 3
      ui/frontend/login.tpl
  44. 2 0
      ui/frontend/masterpage_template.tpl
  45. 47 0
      ui/shared/OMGUtils.js
  46. 6 0
      ui/shared/jquery.js
  47. 36 0
      vite.config.js
  48. 17 10
      webpack.config.js

+ 2 - 0
.gitignore

@@ -11,6 +11,8 @@ Thumbs.db
 composer.phar
 composer.phar
 .idea
 .idea
 .vscode
 .vscode
+.pi
+.spec
 node_modules
 node_modules
 vendor
 vendor
 
 

+ 3 - 15
cfg/config.ini

@@ -1,5 +1,5 @@
 [globals]
 [globals]
-VERSION=1.7.0
+VERSION=1.8.0
 AUTOLOAD="src/"
 AUTOLOAD="src/"
 CACHE = TRUE
 CACHE = TRUE
 UI=ui/frontend/
 UI=ui/frontend/
@@ -9,29 +9,17 @@ HIGHLIGHT=false
 JAR.expire={{time()+31536000}}
 JAR.expire={{time()+31536000}}
 DSN="mysql:host=localhost;port=3306;dbname=omlink"
 DSN="mysql:host=localhost;port=3306;dbname=omlink"
 dbuser=root
 dbuser=root
-dbpass=openmind
+dbpass=Softwd1!
 MAX_RETRY_FOR_FAULT=10
 MAX_RETRY_FOR_FAULT=10
 DEV=true
 DEV=true
 AGGREGATE_TIMEOUT=500
 AGGREGATE_TIMEOUT=500
 BLINKOFF_COLOR=0
 BLINKOFF_COLOR=0
 
 
 ; Path
 ; Path
-BASEURL=/omlink
+BASEURL=/phengine
 SITENAME=OM-Link
 SITENAME=OM-Link
 LOGO_CUSTOMER={{@BASEURL}}/assets/images/logo.png
 LOGO_CUSTOMER={{@BASEURL}}/assets/images/logo.png
 ICON_LOGOUT={{@BASEURL}}/assets/images/logout.png
 ICON_LOGOUT={{@BASEURL}}/assets/images/logout.png
 
 
 ; Select which customer class enable
 ; Select which customer class enable
 CUSTOMERNAME=omlink
 CUSTOMERNAME=omlink
-
-[CORE.fb]
-; Production
-host=192.168.10.140
-port=50443
-; Server TEST
-key=C0000000000011474023057
-pass=ZrgzkvJrOKnNSCnPnGz3u{A6eHSNXVvs
-
-[FINDBOXSERVICE]
-PORT=1234
-MAX_JOBS=1

+ 19 - 0
composer.json

@@ -0,0 +1,19 @@
+{
+    "name": "openmind/omlink",
+    "require": {
+        "php": "^8.5",
+        "bcosca/fatfree-core": "3.9.3"
+    },
+    "autoload": {
+        "psr-4": {
+            "OpenMind\\": "src/OpenMind/",
+            "Modules\\": "src/Modules/"
+        }
+    },
+    "authors": [
+        {
+            "name": "softword",
+            "email": "softword@gvoci.com"
+        }
+    ]
+}

+ 66 - 0
composer.lock

@@ -0,0 +1,66 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "2e8d83a30d4a86562e5e6e4b17ed2446",
+    "packages": [
+        {
+            "name": "bcosca/fatfree-core",
+            "version": "3.9.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/f3-factory/fatfree-core.git",
+                "reference": "fc299466eaa064cce0ec9ceca23a8c8c7b774888"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/f3-factory/fatfree-core/zipball/fc299466eaa064cce0ec9ceca23a8c8c7b774888",
+                "reference": "fc299466eaa064cce0ec9ceca23a8c8c7b774888",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "."
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "GPL-3.0"
+            ],
+            "description": "A powerful yet easy-to-use PHP micro-framework designed to help you build dynamic and robust Web applications - fast!",
+            "homepage": "http://fatfreeframework.com/",
+            "support": {
+                "issues": "https://github.com/f3-factory/fatfree-core/issues",
+                "source": "https://github.com/f3-factory/fatfree-core/tree/3.9.3"
+            },
+            "funding": [
+                {
+                    "url": "https://www.buymeacoffee.com/ikkez",
+                    "type": "buy_me_a_coffee"
+                },
+                {
+                    "url": "https://github.com/ikkez",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-07-21T23:24:48+00:00"
+        }
+    ],
+    "packages-dev": [],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {
+        "php": "^8.5"
+    },
+    "platform-dev": {},
+    "plugin-api-version": "2.9.0"
+}

+ 5 - 8
index.php

@@ -10,28 +10,25 @@
 // Init Framework
 // Init Framework
 use OpenMind\System\OMUtils;
 use OpenMind\System\OMUtils;
 
 
-require_once 'lib/f3/base.php';
+require_once __DIR__ . '/vendor/autoload.php';
 
 
 /** @var \Base $f3 */
 /** @var \Base $f3 */
 $f3 = \Base::instance();
 $f3 = \Base::instance();
 
 
 // Initialize App Menu
 // Initialize App Menu
-$f3->set('GLOBALMENU', array());
-
-// Load Utility Class
-require_once "src/OpenMind/System/OMUtils.php";
+$f3->set('GLOBALMENU', []);
 
 
 // Initialize Util library
 // Initialize Util library
 $frameworkUtils = new OMUtils();
 $frameworkUtils = new OMUtils();
 
 
 // PHP Check Version
 // PHP Check Version
-$frameworkUtils->DieByOldPhpVersion();
+$frameworkUtils->RequirePhp85();
 
 
 // Load configuration
 // Load configuration
 $frameworkUtils->LoadEncryptedConfig('cfg/config.bin');
 $frameworkUtils->LoadEncryptedConfig('cfg/config.bin');
 
 
-// Dynamic Module Loading
-$frameworkUtils->PluginLoader('src/');
+// Application composition
+$frameworkUtils->PluginLoader(__DIR__ . '/src');
 
 
 // Run Engine!!
 // Run Engine!!
 $f3->run();
 $f3->run();

+ 23 - 29
package.json

@@ -3,44 +3,35 @@
   "version": "1.12.1",
   "version": "1.12.1",
   "codename": "Barricade",
   "codename": "Barricade",
   "private": true,
   "private": true,
+  "engines": {
+    "node": "^20.19.0 || >=22.12.0"
+  },
   "description": "OM-Link Engine",
   "description": "OM-Link Engine",
   "author": "Giancarlo Voci",
   "author": "Giancarlo Voci",
   "scripts": {
   "scripts": {
     "clean": "shx --silent rm -rf deploy.tar.gz tmp node_modules ui/admin/node_modules src_minified assets/frontend assets/admin Site.log cfg/*.old ui/*/masterpage.tpl fbtpl *.tgz",
     "clean": "shx --silent rm -rf deploy.tar.gz tmp node_modules ui/admin/node_modules src_minified assets/frontend assets/admin Site.log cfg/*.old ui/*/masterpage.tpl fbtpl *.tgz",
-    "buildDev": "webpack --mode=none",
-    "buildProd": "webpack --mode=production",
-    "watch": "webpack --mode=none --watch",
+    "buildDev": "vite build --mode development",
+    "buildProd": "vite build --mode production",
+    "watch": "vite build --watch --mode development",
     "pack": "npm pack"
     "pack": "npm pack"
   },
   },
   "dependencies": {
   "dependencies": {
-    "bootstrap": "^5.1.3",
-    "datatables.net": "^1.11.5",
-    "datatables.net-bs4": "^1.11.5",
-    "datatables.net-buttons": "^2.2.2",
-    "datatables.net-buttons-bs4": "^2.2.2",
-    "datatables.net-responsive": "^2.2.9",
-    "datatables.net-responsive-bs4": "^2.2.9",
-    "datatables.net-select": "^1.3.4",
-    "datatables.net-select-bs4": "^1.3.4",
-    "jquery": "^3.6.0",
-    "popper.js": "^1.15.0"
+    "@popperjs/core": "^2.11.8",
+    "bootstrap": "^5.3.8",
+    "datatables.net": "^3.0.2",
+    "datatables.net-bs5": "^3.0.2",
+    "datatables.net-buttons": "^4.0.2",
+    "datatables.net-buttons-bs5": "^4.0.2",
+    "datatables.net-responsive": "^4.0.2",
+    "datatables.net-responsive-bs5": "^4.0.2",
+    "datatables.net-select": "^4.0.1",
+    "datatables.net-select-bs5": "^4.0.1",
+    "jquery": "^4.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "acorn": "^8.7.0",
-    "clean-webpack-plugin": "^4.0.0",
-    "css-loader": "^6.7.1",
-    "css-minimizer-webpack-plugin": "^3.4.1",
-    "file-loader": "^6.2.0",
-    "html-webpack-plugin": "^5.5.0",
-    "html-webpack-prefix-plugin": "^1.1.0",
-    "mini-css-extract-plugin": "^2.6.0",
-    "sass": "^1.57.1",
-    "sass-loader": "^12.6.0",
+    "sass": "^1.102.0",
     "shx": "^0.3.4",
     "shx": "^0.3.4",
-    "terser-webpack-plugin": "^5.3.1",
-    "url-loader": "^4.1.1",
-    "webpack": "^5.71.0",
-    "webpack-cli": "^4.9.2"
+    "vite": "^7.1.3"
   },
   },
   "files": [
   "files": [
     "index.php",
     "index.php",
@@ -49,5 +40,8 @@
     "cfg/config.bin",
     "cfg/config.bin",
     "ui/*/*.tpl",
     "ui/*/*.tpl",
     "assets"
     "assets"
-  ]
+  ],
+  "allowScripts": {
+    "@parcel/watcher@2.6.0": true
+  }
 }
 }

+ 36 - 0
scripts/generate-config-bin.sh

@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly SOURCE_FILE="${PROJECT_ROOT}/cfg/config.ini"
+readonly TARGET_FILE="${PROJECT_ROOT}/cfg/config.bin"
+
+if [[ ! -f "${SOURCE_FILE}" ]]; then
+	printf 'Configuration source not found: %s\n' "${SOURCE_FILE}" >&2
+	exit 1
+fi
+
+if [[ -e "${TARGET_FILE}" ]]; then
+	printf 'Configuration target already exists: %s\n' "${TARGET_FILE}" >&2
+	exit 1
+fi
+
+cd "${PROJECT_ROOT}"
+php -r '
+require $argv[1];
+
+$config = file_get_contents($argv[2]);
+if ($config === false) {
+    fwrite(STDERR, "Unable to read the configuration source.\n");
+    exit(1);
+}
+
+$encodedConfig = (new \OpenMind\System\OMUtils())->Encrypt($config);
+if (file_put_contents($argv[3], $encodedConfig) === false) {
+    fwrite(STDERR, "Unable to write the configuration target.\n");
+    exit(1);
+}
+' 'vendor/autoload.php' 'cfg/config.ini' 'cfg/config.bin'
+
+printf 'Created %s\n' "${TARGET_FILE}"

+ 161 - 0
scripts/vite-template-plugin.mjs

@@ -0,0 +1,161 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const ASSET_MARKER = '<!-- VITE_ASSETS -->';
+const DEFAULT_ENTRY = 'ui/frontend/js/frontend.js';
+const DEFAULT_TEMPLATES = [
+	{
+		source: 'ui/frontend/masterpage_template.tpl',
+		output: 'ui/frontend/masterpage.tpl',
+	},
+	{
+		source: 'ui/frontend/errors_template.tpl',
+		output: 'ui/frontend/errors.tpl',
+	},
+];
+
+/**
+ * Normalizes a path for comparisons with Vite manifest keys.
+ *
+ * @param {string} filePath Path to normalize.
+ * @returns {string} A slash-separated path.
+ * @sideEffects None.
+ */
+function normalizeManifestPath(filePath) {
+	return filePath.replaceAll(path.sep, '/');
+}
+
+/**
+ * Resolves a path relative to the Vite project root.
+ *
+ * @param {string} projectRoot Vite project root.
+ * @param {string} filePath Absolute or project-relative path.
+ * @returns {string} An absolute path.
+ * @sideEffects None.
+ */
+function resolveProjectPath(projectRoot, filePath) {
+	return path.resolve(projectRoot, filePath);
+}
+
+/**
+ * Finds the manifest entry for the configured JavaScript entry.
+ *
+ * @param {Record<string, object>} manifest Parsed Vite manifest.
+ * @param {string} projectRoot Vite project root.
+ * @param {string} entryPath Configured JavaScript entry path.
+ * @returns {object} The matching Vite manifest entry.
+ * @sideEffects None.
+ */
+function findEntry(manifest, projectRoot, entryPath) {
+	const relativeEntry = normalizeManifestPath(path.relative(projectRoot, entryPath));
+	const match = Object.entries(manifest).find(([source, asset]) =>
+		asset.isEntry && (normalizeManifestPath(source) === relativeEntry || asset.src === relativeEntry),
+	);
+
+	if (!match) {
+		throw new Error(`Vite manifest entry not found for ${relativeEntry}.`);
+	}
+
+	return match[1];
+}
+
+/**
+ * Creates an asset URL from Vite's configured public base.
+ *
+ * @param {string} baseUrl Vite public base URL.
+ * @param {string} assetPath Manifest asset path.
+ * @returns {string} The public asset URL.
+ * @sideEffects None.
+ */
+function createAssetUrl(baseUrl, assetPath) {
+	return `${baseUrl.replace(/\/?$/, '/')}${assetPath.replace(/^\/+/, '')}`;
+}
+
+/**
+ * Builds script and stylesheet tags from a Vite manifest entry.
+ *
+ * @param {object} entry Vite manifest entry.
+ * @param {string} baseUrl Vite public base URL.
+ * @returns {string} HTML asset tags.
+ * @sideEffects None.
+ */
+function createAssetTags(entry, baseUrl) {
+	const cssTags = (entry.css ?? []).map(
+		assetPath => `\t<link rel="stylesheet" href="${createAssetUrl(baseUrl, assetPath)}">`,
+	);
+	const scriptTag = `\t<script type="module" src="${createAssetUrl(baseUrl, entry.file)}"></script>`;
+
+	return [...cssTags, scriptTag].join('\n');
+}
+
+/**
+ * Replaces the asset marker in a source template and writes its generated output.
+ *
+ * @param {string} projectRoot Vite project root.
+ * @param {{source: string, output: string}} template Template source and output paths.
+ * @param {string} assetTags Generated Vite asset tags.
+ * @returns {void}
+ * @sideEffects Reads and writes template files.
+ */
+function generateTemplate(projectRoot, template, assetTags) {
+	const sourcePath = resolveProjectPath(projectRoot, template.source);
+	const outputPath = resolveProjectPath(projectRoot, template.output);
+	const source = fs.readFileSync(sourcePath, 'utf8');
+	const markerCount = source.split(ASSET_MARKER).length - 1;
+
+	if (markerCount !== 1) {
+		throw new Error(`${sourcePath} must contain exactly one ${ASSET_MARKER} marker.`);
+	}
+
+	fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+	fs.writeFileSync(outputPath, source.replace(ASSET_MARKER, assetTags), 'utf8');
+}
+
+/**
+ * Creates a Vite plugin that generates F3 templates from the build manifest.
+ *
+ * @param {{entry?: string, templates?: Array<{source: string, output: string}>}} options Plugin configuration.
+ * @returns {import('vite').Plugin} Vite plugin instance.
+ * @sideEffects Writes generated templates after a successful build.
+ */
+export default function viteTemplatePlugin({
+	entry = DEFAULT_ENTRY,
+	templates = DEFAULT_TEMPLATES,
+} = {}) {
+	let resolvedConfig;
+
+	return {
+		name: 'vite-template-plugin',
+		apply: 'build',
+
+		/**
+		 * Stores the resolved Vite configuration for post-build path resolution.
+		 *
+		 * @param {import('vite').ResolvedConfig} config Resolved Vite configuration.
+		 * @returns {void}
+		 * @sideEffects Updates plugin state.
+		 */
+		configResolved(config) {
+			resolvedConfig = config;
+		},
+
+		/**
+		 * Generates F3 templates after Vite has written the manifest.
+		 *
+		 * @returns {void}
+		 * @sideEffects Reads the manifest and writes generated template files.
+		 */
+		closeBundle() {
+			const manifestName = resolvedConfig.build.manifest === true
+				? '.vite/manifest.json'
+				: resolvedConfig.build.manifest;
+			const manifestPath = resolveProjectPath(resolvedConfig.build.outDir, manifestName);
+			const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
+			const entryPath = resolveProjectPath(resolvedConfig.root, entry);
+			const manifestEntry = findEntry(manifest, resolvedConfig.root, entryPath);
+			const assetTags = createAssetTags(manifestEntry, resolvedConfig.base);
+
+			templates.forEach(template => generateTemplate(resolvedConfig.root, template, assetTags));
+		},
+	};
+}

+ 5 - 0
src/Modules/Blank/Blank.php

@@ -13,6 +13,11 @@ use OpenMind\System\UserRoles;
 
 
 class Blank extends PluginStrategy
 class Blank extends PluginStrategy
 {
 {
+	/**
+	 * Registers the demo route, menu entry, and permission role.
+	 *
+	 * @return void
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		$this->AddRoute('GET /demo', __NAMESPACE__ . '\Pages->HelloWorld');
 		$this->AddRoute('GET /demo', __NAMESPACE__ . '\Pages->HelloWorld');

+ 21 - 31
src/Modules/Blank/Pages.php

@@ -9,8 +9,6 @@
 namespace Modules\Blank;
 namespace Modules\Blank;
 
 
 use Base;
 use Base;
-use OpenMind\FindBox\QueueCommand;
-use OpenMind\System\QueueSystem;
 use OpenMind\Web\Controller;
 use OpenMind\Web\Controller;
 use Template;
 use Template;
 
 
@@ -18,7 +16,11 @@ class Pages extends Controller
 {
 {
 
 
 	/**
 	/**
-	 * @param Base $f3
+	 * Renders the demo greeting page.
+	 *
+	 * @param Base $f3 F3 application instance.
+	 * @return void
+	 * @sideEffects Updates the F3 template variables and writes the rendered page.
 	 */
 	 */
 	public function HelloWorld($f3)
 	public function HelloWorld($f3)
 	{
 	{
@@ -27,30 +29,13 @@ class Pages extends Controller
 		echo Template::instance()->render('masterpage.tpl');
 		echo Template::instance()->render('masterpage.tpl');
 	}
 	}
 
 
-	public function BlinkON(array $labels)
-	{
-		$qs = new QueueSystem();
-		$cmd = new QueueCommand($labels);
-		$cmd->setLeds(2, 1, 1000, 300, 1);
-		$qs->send($cmd);
-	}
-
-	public function BlinkOFF(array $labels)
-	{
-		$qs = new QueueSystem();
-		$cmd = new QueueCommand($labels);
-		$cmd->setToBlinkOff();
-		$qs->send($cmd);
-	}
-
-	public function BlinkONW(array $labels)
-	{
-		$qs = new QueueSystem();
-		$cmd = new QueueCommand($labels);
-		$cmd->setLeds(2, 1, 1000, 300, 0);
-		$qs->send($cmd);
-	}
-	
+	/**
+	 * Handles picking-list requests and returns a JSON response.
+	 *
+	 * @param Base $f3 F3 application instance.
+	 * @return void
+	 * @sideEffects Reads and updates picking-list records and writes JSON output.
+	 */
 	public function PickinglistController($f3)
 	public function PickinglistController($f3)
 	{
 	{
 		header('Content-Type: application/json');
 		header('Content-Type: application/json');
@@ -61,7 +46,6 @@ class Pages extends Controller
 			case 'getData':
 			case 'getData':
 				echo $this->getTableData();
 				echo $this->getTableData();
 				return;
 				return;
-				break;
 
 
 			case 'saveall':
 			case 'saveall':
 				$ret = $this->SaveProduct();
 				$ret = $this->SaveProduct();
@@ -104,17 +88,23 @@ class Pages extends Controller
 
 
 	}
 	}
 
 
+	/**
+	 * Loads picking-list rows and serializes them as JSON.
+	 *
+	 * @return string JSON-encoded picking-list rows.
+	 * @sideEffects Reads picking-list records from the configured database.
+	 */
 	private function getTableData(): string
 	private function getTableData(): string
 	{
 	{
 		global $f3;
 		global $f3;
 
 
 		$data = $f3->get('DB')->exec("SELECT * FROM pickinglist");
 		$data = $f3->get('DB')->exec("SELECT * FROM pickinglist");
 
 
-		$data2 = array_map(function ($v) {
-			return array_values($v);
+		$rows = array_map(function ($row) {
+			return array_values($row);
 		}, $data);
 		}, $data);
 
 
-		return json_encode($data2);
+		return json_encode($rows);
 	}
 	}
 
 
 }
 }

+ 6 - 9
src/OpenMind/Admin/Admin.php

@@ -1,4 +1,4 @@
-<?
+<?php
 /**
 /**
  * @author Giancarlo Voci
  * @author Giancarlo Voci
  * @date 04/12/2018
  * @date 04/12/2018
@@ -12,11 +12,14 @@ use OpenMind\System\UserRoles;
 
 
 class Admin extends PluginStrategy
 class Admin extends PluginStrategy
 {
 {
+	/**
+	 * Registers the administration routes, menu entries, and permission role.
+	 *
+	 * @return void
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		$this->AddRoute('GET @admin: /admin', __NAMESPACE__ . '\Pages->Admin');
 		$this->AddRoute('GET @admin: /admin', __NAMESPACE__ . '\Pages->Admin');
-		$this->AddRoute('GET @admin: /admin/eslstatus', __NAMESPACE__ . '\Pages->ESLStatus');
-		$this->AddRoute('GET @admin: /admin/apstatus', __NAMESPACE__ . '\Pages->APStatus');
 		$this->AddRoute('GET @admin: /admin/omlsettings', __NAMESPACE__ . '\Pages->OML_Settings');
 		$this->AddRoute('GET @admin: /admin/omlsettings', __NAMESPACE__ . '\Pages->OML_Settings');
 		$this->AddRoute('GET @admin: /admin/users', __NAMESPACE__ . '\Pages->Users');
 		$this->AddRoute('GET @admin: /admin/users', __NAMESPACE__ . '\Pages->Users');
 
 
@@ -30,12 +33,6 @@ class Admin extends PluginStrategy
 		$this->AddMenuItem('OM-Link Settings', ['mainMenu' => 'System Settings', 'url' => 'admin/omlsettings', 'permissionName' => 'admin']);
 		$this->AddMenuItem('OM-Link Settings', ['mainMenu' => 'System Settings', 'url' => 'admin/omlsettings', 'permissionName' => 'admin']);
 		$this->AddMenuItem('Users', ['mainMenu' => 'System Settings', 'url' => 'admin/users', 'permissionName' => 'admin']);
 		$this->AddMenuItem('Users', ['mainMenu' => 'System Settings', 'url' => 'admin/users', 'permissionName' => 'admin']);
 
 
-		// Menu: ESL Ops
-		$this->AddMenuItem('ESL Ops', ['mainMenu' => 'adminMainMenu', 'mdiClass' => 'label', 'permissionName' => 'admin']);
-		$this->AddMenuItem('Status', ['mainMenu' => 'ESL Ops', 'url' => 'admin/eslstatus', 'permissionName' => 'admin']);
-		$this->AddMenuItem('AP Status', ['mainMenu' => 'ESL Ops', 'url' => 'admin/apstatus', 'permissionName' => 'admin']);
-		$this->AddMenuItem('', ['mainMenu' => 'ESL Ops', 'url' => 'admin/apstatus', 'permissionName' => 'admin']);
-
 		$roles = new UserRoles();
 		$roles = new UserRoles();
 		$roles->add('admin');
 		$roles->add('admin');
 	}
 	}

+ 112 - 181
src/OpenMind/Admin/Pages.php

@@ -9,237 +9,168 @@ namespace OpenMind\Admin;
 
 
 use Base;
 use Base;
 use Exception;
 use Exception;
-use OpenMind\FindBox\FindBoxCoreSystem;
-use OpenMind\FindBox\SmartESL;
-use OpenMind\System\Notifications;
+use OpenMind\System\OMUtils;
 use OpenMind\System\User;
 use OpenMind\System\User;
 use OpenMind\Web\Controller;
 use OpenMind\Web\Controller;
 use Template;
 use Template;
 
 
 class Pages extends Controller
 class Pages extends Controller
 {
 {
-	private $findBoxCore = null;
+	private const CONFIGURATION_BACKUP_FILE = 'cfg/config.bin.old';
+	private const CONFIGURATION_FILE = 'cfg/config.bin';
+	private const LOAD_CONFIGURATION_METHOD = 'loadconfig';
+	private const SAVE_CONFIGURATION_METHOD = 'saveconfig';
 
 
 	/**
 	/**
-	 * Pages constructor.
-	 * @param Base $f3
+	 * Initializes the admin controller and configures its template directory.
+	 *
+	 * @param Base $f3 Framework instance.
+	 * @return void
+	 * @sideEffects Changes the framework UI directory.
 	 */
 	 */
-	public function __construct($f3)
+	public function __construct(Base $f3)
 	{
 	{
 		parent::__construct($f3);
 		parent::__construct($f3);
-
-		try {
-			$this->findBoxCore = new FindBoxCoreSystem(...array_values($f3->get('CORE.fb')));
-		} catch (Exception $e) {
-			$ns = new Notifications();
-			$ns->notify(Notifications::FB_ADMIN, $f3->clean($e->getMessage()));
-			// IF can't connect to FINDBOX, reroute to OMLINK Page Settings to fast change settings
-			if (!$f3->get('AJAX') && ($f3->get('PATTERN') != '/admin/omlsettings'))
-				$f3->reroute('/admin/omlsettings');
-		}
-
-		// Set Admin F3 UI folder
 		$f3->set('UI', 'ui/admin/');
 		$f3->set('UI', 'ui/admin/');
 	}
 	}
 
 
-	public function Admin($f3)
-	{
-		echo Template::instance()->render('masterpage.tpl');
-	}
-
-	public function ESLStatus(Base $f3)
+	/**
+	 * Renders the admin landing page.
+	 *
+	 * @param Base $f3 Framework instance.
+	 * @return void
+	 * @sideEffects Sends the rendered template to the response.
+	 */
+	public function Admin(Base $f3): void
 	{
 	{
-		$eslStatus = $this->findBoxCore->GetAllESLStatus();
-
-		$f3->set('STATUS_BADGE_COLOR', [
-			'OFFLINE' => 'warning',
-			'BLOCKED,FC' => 'danger',
-			'NO_STATUS' => 'light',
-			'OK' => 'success'
-		]);
-
-		$f3->set('BATLEV_ICONS', [
-			'GOOD' => 'full', // 100% - 60%
-			'LOW' => 'half',  //  60% - 30%
-			'BAD' => 'empty', //  30% - 0%
-		]);
-
-		$f3->set('ESL_STATUS', $eslStatus);
-		$f3->set('PAGENAME', 'Dashboard');
-		$f3->set('CONTENT', 'labelstatus.tpl');
+		$f3->set('PAGENAME', 'Admin');
 		echo Template::instance()->render('masterpage.tpl');
 		echo Template::instance()->render('masterpage.tpl');
 	}
 	}
 
 
-	public function OML_Settings(Base $f3)
+	/**
+	 * Renders the encrypted configuration editor.
+	 *
+	 * @param Base $f3 Framework instance.
+	 * @return void
+	 * @sideEffects Reads the configuration file and sends the rendered template.
+	 * @throws Exception When the configuration file cannot be read.
+	 */
+	public function OML_Settings(Base $f3): void
 	{
 	{
 		global $frameworkUtils;
 		global $frameworkUtils;
 
 
-		$path = 'cfg/config.bin';
-		if (!file_exists($path))
-			throw new Exception('Config file not found');
-
-		$data = file_get_contents($path);
-		$config = $frameworkUtils->Decrypt($data);
-
+		$config = $this->readConfiguration($frameworkUtils);
 		$f3->set('PAGENAME', 'OM-Link Settings');
 		$f3->set('PAGENAME', 'OM-Link Settings');
 		$f3->set('CONTENT', 'omlsettings.tpl');
 		$f3->set('CONTENT', 'omlsettings.tpl');
 		$f3->set('CONFIG_TEXT', $config);
 		$f3->set('CONFIG_TEXT', $config);
 		echo Template::instance()->render('masterpage.tpl');
 		echo Template::instance()->render('masterpage.tpl');
 	}
 	}
 
 
-	public function Config(Base $f3)
+	/**
+	 * Loads or saves configuration data requested by the admin UI.
+	 *
+	 * @param Base $f3 Framework instance.
+	 * @return void
+	 * @sideEffects Reads or replaces the encrypted configuration file and sends JSON.
+	 */
+	public function Config(Base $f3): void
 	{
 	{
 		global $frameworkUtils;
 		global $frameworkUtils;
-		$config = $f3->get('BODY');
-		if (strlen($config) == 0) {
-			echo json_encode([
-				"err" => "Empty body command"
-			]);
+
+		$request = json_decode($f3->get('BODY'));
+		if (!is_object($request) || !isset($request->method)) {
+			$this->sendJson(['err' => 'Invalid configuration command']);
 			return;
 			return;
 		}
 		}
 
 
-		$configOBJ = json_decode($config);
-		switch ($configOBJ->method) {
-
-			case 'saveconfig':
-				$newConfig = $frameworkUtils->Encrypt($configOBJ->value);
-				@rename('cfg/config.bin', 'cfg/config.bin.old');
-				@file_put_contents('cfg/config.bin', $newConfig);
-				echo json_encode([
-					"err" => "Configuration saved"
-				]);
-				break;
-
-			case 'loadconfig':
-				$config = $frameworkUtils->Decrypt(file_get_contents('cfg/config.bin'));
-				echo json_encode([
-					"err" => "Configuration reloaded successfully",
-					"value" => $config
-				]);
-				break;
-
-			case 'geteslstatus':
-
-				$labelID = $configOBJ->label_id;
-				$esl = new SmartESL($this->findBoxCore, [$labelID]);
-				$status = $esl->GetStatus(true);
-
-				$svgPath = $this->findBoxCore->GetTemplateImage($status[$configOBJ->label_id]['data']['template_id']);
-
-				echo json_encode([
-					"err" => "Status reloaded successfully",
-					"value" => $status[$configOBJ->label_id],
-					"svgPath" => $svgPath
-				]);
-
-				break;
-
-			case 'blinklabels':
-				$esl = new SmartESL($this->findBoxCore, [$configOBJ->labelid]);
-				$ret = $esl->BlinkSpecial(2, 1, 1000, 5, 0);
-
-				if ($ret['err'] == 0 && $ret['value']['code'] == 0) {
-					if ($ret['value']['value'][$configOBJ->labelid])
-						echo json_encode([
-							"err" => 0,
-							"value" => "{$configOBJ->labelid} blink successfull"
-						]);
-					else
-						echo json_encode([
-							"err" => 1,
-							"value" => "{$configOBJ->labelid} blink error"
-						]);
-				}
-				break;
-
-			case 'unbind':
-				$ret = $this->findBoxCore->UnBind($configOBJ->labelid);
-				if ($ret['err'] != 0) {
-					echo json_encode($ret);
+		switch ($request->method) {
+			case self::SAVE_CONFIGURATION_METHOD:
+				if (!isset($request->value) || !is_string($request->value)) {
+					$this->sendJson(['err' => 'Configuration value is required']);
 					return;
 					return;
 				}
 				}
 
 
-				echo json_encode([
-					"err" => 0,
-					"value" => "{$configOBJ->labelid} unbind successfull"
-				]);
-				break;
-
-			case 'resetfailcounter':
-				$URI = "/1.0.0/index.php/smartesl.resetfailcounter/1.0.0/[[\"{$configOBJ->labelid}\"]]";
-				$this->findBoxCore->CallAPI($URI, "");
+				$this->saveConfiguration($frameworkUtils->Encrypt($request->value));
+				$this->sendJson(['err' => 'Configuration saved']);
+				return;
 
 
-				echo json_encode([
-					"err" => 0,
-					"value" => "{$configOBJ->labelid} Reset Fail Counter successfull"
+			case self::LOAD_CONFIGURATION_METHOD:
+				$this->sendJson([
+					'err' => 'Configuration reloaded successfully',
+					'value' => $this->readConfiguration($frameworkUtils),
 				]);
 				]);
+				return;
 
 
-				break;
-
-			case 'TemplateSelector':
-				$this->TemplateSelector($f3, $configOBJ);
-				break;
-
-			case 'SetDisplay':
-				foreach($configOBJ->labels as $labelID) {
-					$esl = new SmartESL($this->findBoxCore, [$labelID]);
-					$esl->Display($configOBJ->id, $labelID);
-				}
-				echo json_encode([
-					"err" => "Display command successfull"
-				]);
-				break;
-
+			default:
+				$this->sendJson(['err' => 'Unsupported configuration command']);
 		}
 		}
-		return;
 	}
 	}
 
 
-	private function TemplateSelector(Base $f3, $Command)
+	/**
+	 * Renders the user-management page.
+	 *
+	 * @param Base $f3 Framework instance.
+	 * @return void
+	 * @sideEffects Reads users from the database and sends the rendered template.
+	 */
+	public function Users(Base $f3): void
 	{
 	{
-		global $frameworkUtils;
-
-		$fb_template = $this->findBoxCore->CallAPI('/1.0.0/index.php/smartesl.gettemplates/1.0.0/', '[]');
-		$templates = $this->findBoxCore->getLabelsFromResponse($fb_template);
-
-		if ($Command->clearCache == 1)
-			$frameworkUtils->rrmdir("fbtpl");
-
-		$tplFileNames = [];
-		if ($templates != null) {
-			foreach($templates as $item) {
+		$users = new User('');
+		$f3->set('USERLIST', $users->find());
+		$f3->set('PAGENAME', 'User Management');
+		$f3->set('CONTENT', 'users.tpl');
+		echo Template::instance()->render('masterpage.tpl');
+	}
 
 
-				if (!in_array($item['resolution'], $Command->resolution))
-					continue;
+	/**
+	 * Reads and decrypts the application configuration.
+	 *
+	 * @param OMUtils $frameworkUtils Configuration utility service.
+	 * @return string Decrypted configuration.
+	 * @sideEffects Reads the configuration file.
+	 * @throws Exception When the configuration file cannot be read.
+	 */
+	private function readConfiguration(OMUtils $frameworkUtils): string
+	{
+		$config = file_get_contents(self::CONFIGURATION_FILE);
+		if ($config === false) {
+			throw new Exception('Configuration file could not be read');
+		}
 
 
-				if (!file_exists("fbtpl/{$item['template_id']}.svg"))
-					$this->findBoxCore->GetTemplateImage($item['template_id']);
+		return $frameworkUtils->Decrypt($config);
+	}
 
 
-				$tplFileNames[] = [
-					"id" => $item['template_id'],
-					"name" => $item['name'],
-					"resolution" => $item['resolution'],
-					"title" => $item['name'],
-					"svg" => file_get_contents("fbtpl/{$item['template_id']}.svg")
-				];
-			}
+	/**
+	 * Replaces the configuration file while retaining the previous version as backup.
+	 *
+	 * @param string $configuration Encrypted configuration.
+	 * @return void
+	 * @sideEffects Renames and writes configuration files.
+	 * @throws Exception When the backup or write fails.
+	 */
+	private function saveConfiguration(string $configuration): void
+	{
+		if (!rename(self::CONFIGURATION_FILE, self::CONFIGURATION_BACKUP_FILE)) {
+			throw new Exception('Configuration backup could not be created');
 		}
 		}
 
 
-		$f3->set('TPLOBJ', $tplFileNames);
-		$tpl = Template::instance()->render('templates.tpl');
-
-		echo json_encode([
-			"err" => 0,
-			"value" => $tpl
-		]);
+		if (file_put_contents(self::CONFIGURATION_FILE, $configuration) !== false) {
+			return;
+		}
 
 
+		rename(self::CONFIGURATION_BACKUP_FILE, self::CONFIGURATION_FILE);
+		throw new Exception('Configuration file could not be written');
 	}
 	}
 
 
-	public function Users(Base $f3)
+	/**
+	 * Sends a JSON response.
+	 *
+	 * @param array<string, string> $response Response data.
+	 * @return void
+	 * @sideEffects Writes JSON to the HTTP response.
+	 */
+	private function sendJson(array $response): void
 	{
 	{
-		$listaUtenti = new User('');
-		$f3->set('USERLIST', $listaUtenti->find());
-		$f3->set('PAGENAME', 'User Management');
-		$f3->set('CONTENT', 'users.tpl');
-		echo Template::instance()->render('masterpage.tpl');
+		echo json_encode($response);
 	}
 	}
 }
 }

+ 109 - 0
src/OpenMind/System/ApplicationComposition.php

@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenMind\System;
+
+use FilesystemIterator;
+use OpenMind\Web\Web;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+
+final class ApplicationComposition
+{
+	private readonly string $sourcePath;
+
+	/**
+	 * Configure the source directory used for plugin discovery.
+	 *
+	 * @param string $sourcePath Root directory containing plugin modules.
+	 * @side-effects Stores the normalized plugin source path.
+	 */
+	public function __construct(string $sourcePath)
+	{
+		$this->sourcePath = rtrim($sourcePath, '/\\');
+	}
+
+	/**
+	 * Create the core modules first, then load the remaining plugin modules.
+	 *
+	 * @return void
+	 * @side-effects Instantiates the core and discovered plugin modules.
+	 */
+	public function boot(): void
+	{
+		$this->bootPlugin(System::class);
+		$this->bootPlugin(Web::class);
+
+		foreach ($this->pluginClasses() as $className) {
+			if ($className === System::class || $className === Web::class) {
+				continue;
+			}
+
+			$this->bootPlugin($className);
+		}
+	}
+
+	/**
+	 * Instantiate one discovered plugin module and record its load start time.
+	 *
+	 * @param string $className Plugin class to instantiate.
+	 * @return void
+	 * @side-effects Instantiates the plugin and updates its load timestamp.
+	 */
+	private function bootPlugin(string $className): void
+	{
+		$plugin = new $className();
+		PluginStrategy::setTsStartLoad();
+	}
+
+	/**
+	 * Discover plugin classes from the source directory structure.
+	 *
+	 * @return list<class-string<PluginStrategy>> Discovered plugin classes.
+	 * @side-effects Loads class metadata while scanning the source tree.
+	 */
+	private function pluginClasses(): array
+	{
+		$classes = [];
+		$directories = new RecursiveIteratorIterator(
+			new RecursiveDirectoryIterator($this->sourcePath, FilesystemIterator::SKIP_DOTS),
+			RecursiveIteratorIterator::CHILD_FIRST,
+		);
+
+		foreach ($directories as $directory) {
+			if (!$directory->isDir()) {
+				continue;
+			}
+
+			$moduleInitPath = "{$directory->getPathname()}/{$directory->getFilename()}.php";
+			if (!is_file($moduleInitPath)) {
+				continue;
+			}
+
+			$className = $this->classNameForDirectory($directory->getPathname());
+			if (!class_exists($className) || !is_subclass_of($className, PluginStrategy::class)) {
+				continue;
+			}
+
+			$classes[] = $className;
+		}
+
+		return array_values(array_unique($classes));
+	}
+
+	/**
+	 * Derive the plugin class name from its source directory convention.
+	 *
+	 * @param string $directoryPath Plugin source directory.
+	 * @return string Fully qualified plugin class name.
+	 */
+	private function classNameForDirectory(string $directoryPath): string
+	{
+		$relativePath = substr($directoryPath, strlen($this->sourcePath) + 1);
+		$namespace = str_replace(['/', '\\'], '\\', $relativePath);
+		$class = basename(str_replace('\\', '/', $relativePath));
+
+		return "{$namespace}\\{$class}";
+	}
+}

+ 39 - 33
src/OpenMind/System/ErrorHandlers.php

@@ -7,71 +7,77 @@
 
 
 namespace OpenMind\System;
 namespace OpenMind\System;
 
 
-
 use Base;
 use Base;
 use Template;
 use Template;
 
 
 class ErrorHandlers
 class ErrorHandlers
 {
 {
-	private $f3;
+	private const LOG_DATE_FORMAT = 'd/m/Y H:i:s';
+
+	private Base $f3;
 
 
+	/**
+	 * Initializes the error handler with the current framework instance.
+	 *
+	 * @return void
+	 * @sideEffects Reads the framework singleton.
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		$this->f3 = Base::instance();
 		$this->f3 = Base::instance();
 	}
 	}
 
 
 	/**
 	/**
-	 * Main error handler: managed AJAX and non-AJAX calls
+	 * Renders or delegates an application error according to the request type.
+	 *
+	 * @return false|null False lets F3 render AJAX errors; null follows rendered web errors.
+	 * @sideEffects Clears output buffers, writes a log entry, and renders or exits.
 	 */
 	 */
 	public function Handler()
 	public function Handler()
 	{
 	{
-		$arrayError = array($this->f3->get('ERROR')['code'], $this->f3->get('ERROR')['status'], $this->f3->get('ERROR')['text']);
+		$error = $this->f3->get('ERROR');
+		$errorValues = [$error['code'], $error['status'], $error['text']];
 
 
-		while (ob_get_level()) // Prevent to output all previous templates
+		while (ob_get_level() > 0) {
 			ob_end_clean();
 			ob_end_clean();
+		}
 
 
-		$err = $this->f3->get('ERROR');
-
-		$this->WriteLog(join(' - ', array($err['code'], $err['status'], $err['text'])));
+		$this->writeLog(implode(' - ', $errorValues));
 
 
 		if ($this->f3->get('CLI')) {
 		if ($this->f3->get('CLI')) {
-			print_r($arrayError);
-			die(1);
+			print_r($errorValues);
+			exit(1);
 		}
 		}
 
 
 		if (!$this->f3->get('AJAX')) {
 		if (!$this->f3->get('AJAX')) {
 			$this->f3->set('UI', 'ui/frontend/');
 			$this->f3->set('UI', 'ui/frontend/');
 			echo Template::instance()->render('errors.tpl');
 			echo Template::instance()->render('errors.tpl');
-			return;
+			return null;
 		}
 		}
 
 
-		// with return FALSE i leave to F3 the output of error code in AJAX
-		//http_response_code(400);
-		//echo join(' - ', $arrayError);
-		return FALSE;
+		return false;
 	}
 	}
 
 
 	/**
 	/**
-	 * Simple function to write to default log file
-	 * @param string $logMessage
-	 * @param int $traceNumber trace index of debug_backtrace() return array, default 1
+	 * Writes an error message with the relevant caller context.
+	 *
+	 * @param string $message Error message.
+	 * @param int $traceIndex Caller index in the debug trace.
+	 * @return void
+	 * @sideEffects Writes to the application log.
 	 */
 	 */
-	private function WriteLog(string $logMessage, int $traceNumber = 1)
+	private function writeLog(string $message, int $traceIndex = 1): void
 	{
 	{
 		$trace = debug_backtrace();
 		$trace = debug_backtrace();
-
-		if (count($trace) > 1) {
-			$trace = $trace[$traceNumber];
-
-			$method = isset($trace['function']) ? $trace['function'] . ':' : '';
-			$class = isset($trace['class']) ? $trace['class'] . ':' : '';
-			$line = isset($trace['line']) ? $trace['line'] . ':' : '';
-			//$userAgent = '(' . join(', ', $UA) . ')';
-			OMUtils::getInstance()->getLogger()->write("{$line}{$class}{$method} " . str_replace(array("\r", "\n"), '', $logMessage), 'd/m/Y H:i:s');
-			return;
-		}
-
-		OMUtils::getInstance()->getLogger()->write($logMessage, 'd/m/Y H:i:s');
+		$context = $trace[$traceIndex] ?? [];
+		$prefix = ($context['line'] ?? '') . ':'
+			. ($context['class'] ?? '') . ':'
+			. ($context['function'] ?? '') . ':';
+		$sanitizedMessage = str_replace(["\r", "\n"], '', $message);
+
+		OMUtils::getInstance()->getLogger()->write(
+			"{$prefix} {$sanitizedMessage}",
+			self::LOG_DATE_FORMAT,
+		);
 	}
 	}
-
 }
 }

+ 41 - 27
src/OpenMind/System/Notifications.php

@@ -1,10 +1,4 @@
 <?php
 <?php
-/**
- * Created by PhpStorm.
- * User: Giancarlo
- * Date: 29/08/2018
- * Time: 16:36
- */
 
 
 namespace OpenMind\System;
 namespace OpenMind\System;
 
 
@@ -12,52 +6,71 @@ use DB\SQL\Mapper;
 
 
 class Notifications extends Mapper
 class Notifications extends Mapper
 {
 {
-	const FB_GENERIC = 0x01;
-	const FB_IMPORT = 0x02;
-	const FB_ADMIN = 0x03;
-	const FB_NOTALIVE = 0x0B;
+	public const FB_GENERIC = 0x01;
+	public const FB_IMPORT = 0x02;
+	public const FB_ADMIN = 0x03;
+	public const FB_NOTALIVE = 0x0B;
+
 	/**
 	/**
-	 * If you need custom notification or to simply send a command to client use FB_CUSTOM_NOTIFICATION+xxx where xxx is a number that identify your notification
+	 * If you need custom notification or to simply send a command to client use
+	 * FB_CUSTOM_NOTIFICATION+xxx where xxx is a number that identify your notification.
 	 */
 	 */
-	const FB_CUSTOM_NOTIFICATION = 0x4000;
-	const FB_REFRESH_FILTERS = Notifications::FB_CUSTOM_NOTIFICATION + 1;
+	public const FB_CUSTOM_NOTIFICATION = 0x4000;
+	public const FB_REFRESH_FILTERS = self::FB_CUSTOM_NOTIFICATION + 1;
+
+	private const TABLE_NAME = 'notifications';
+	private const CODE_CONDITION = 'code=?';
 
 
+	/**
+	 * Initialize the notification mapper.
+	 *
+	 * @side-effects Connects the mapper to the configured database.
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		global $f3;
 		global $f3;
 
 
-		parent::__construct($f3->get('DB'), 'notifications');
+		parent::__construct($f3->get('DB'), self::TABLE_NAME);
 	}
 	}
 
 
 	/**
 	/**
-	 * @param int $code of notification to remove from database
+	 * Remove all notifications matching the supplied code.
+	 *
+	 * @param int $code Notification code to remove from the database.
+	 * @return void
+	 * @side-effects Deletes matching notification records.
 	 */
 	 */
 	public function Remove(int $code)
 	public function Remove(int $code)
 	{
 	{
 		$this->reset();
 		$this->reset();
-		$this->erase(['code=?', $code]);
+		$this->erase([self::CODE_CONDITION, $code]);
 	}
 	}
 
 
 	/**
 	/**
-	 * Truncate all table
+	 * Remove every notification from the table.
+	 *
+	 * @return void
+	 * @side-effects Truncates the notifications table.
 	 */
 	 */
 	public function emptyAll()
 	public function emptyAll()
 	{
 	{
 		global $f3;
 		global $f3;
 
 
-		$f3->get('DB')->exec('TRUNCATE TABLE notifications');
+		$f3->get('DB')->exec('TRUNCATE TABLE ' . self::TABLE_NAME);
 	}
 	}
 
 
 	/**
 	/**
-	 * Send custom notification to system
+	 * Send a custom notification to the system.
 	 *
 	 *
-	 * @param int $code maybe FB_GENERIC, FB_IMPORT, etc
-	 * @param string|null $caption
-	 * @param null $link
+	 * @param int $code Notification code, such as FB_GENERIC or FB_IMPORT.
+	 * @param string|null $caption Notification caption.
+	 * @param mixed $link Optional notification link.
+	 * @return void
+	 * @side-effects Persists the notification when the code is allowed.
 	 */
 	 */
 	public function notify(int $code, string $caption = null, $link = null)
 	public function notify(int $code, string $caption = null, $link = null)
 	{
 	{
-		if ($code == $this::FB_GENERIC || $this->check($code)) {
+		if ($code === $this::FB_GENERIC || $this->check($code)) {
 			$this->reset();
 			$this->reset();
 			$this->code = $code;
 			$this->code = $code;
 			$this->caption = $caption;
 			$this->caption = $caption;
@@ -67,14 +80,15 @@ class Notifications extends Mapper
 	}
 	}
 
 
 	/**
 	/**
-	 * Check if notification $code exists
+	 * Check whether a notification with the supplied code exists.
 	 *
 	 *
-	 * @param int $code
-	 * @return bool
+	 * @param int $code Notification code to check.
+	 * @return bool Whether a matching notification exists.
+	 * @side-effects Loads matching records into the mapper.
 	 */
 	 */
 	public function check(int $code)
 	public function check(int $code)
 	{
 	{
-		$this->load(['code=?', $code]);
+		$this->load([self::CODE_CONDITION, $code]);
 		return $this->dry();
 		return $this->dry();
 	}
 	}
 }
 }

+ 50 - 57
src/OpenMind/System/OMUtils.php

@@ -8,15 +8,13 @@
 namespace OpenMind\System;
 namespace OpenMind\System;
 
 
 use Exception;
 use Exception;
-use FilesystemIterator;
 use Log;
 use Log;
-use OpenMind\Web\Web;
-use RecursiveDirectoryIterator;
-use RecursiveIteratorIterator;
+use RuntimeException;
 use Template;
 use Template;
 
 
 class OMUtils
 class OMUtils
 {
 {
+	private const DEFAULT_SOCKET_TIMEOUT_SECONDS = 5;
 
 
 	private $Logger;
 	private $Logger;
 
 
@@ -50,35 +48,11 @@ class OMUtils
 	}
 	}
 
 
 	/**
 	/**
-	 * Load all modules in <$path> folder
-	 * @param string $path
+	 * Load all application modules through the explicit composition module.
 	 */
 	 */
-	public function PluginLoader(string $path)
+	public function PluginLoader(string $path): void
 	{
 	{
-		// <class priority>
-		// TODO if is necessary to give a priority to some classes before other, initialize here
-		Global $System, $Web;
-		$System = new System();  // we need System Base class first, so will force to load as first
-		$Web = new Web();
-		// </class priority>
-		$Directory = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS);
-		$Iterator = new RecursiveIteratorIterator($Directory, RecursiveIteratorIterator::CHILD_FIRST);
-
-		foreach($Iterator as $item) {
-			if (is_file($item->getPathname()))
-				continue;
-			$moduleName = $item->getFilename();
-			$className = str_replace('/', '\\', ltrim("{$item->getPathname()}/{$moduleName}", $path));
-			// Load only PHP file with same name of namespace ex.: \Lamborghini\Lamborghini.php
-			$moduleInitPath = "{$item->getPathname()}/{$moduleName}.php";
-			if (file_exists($moduleInitPath)) { // If already imported, skip
-				if (!isset($$moduleName)) {
-					Global $$moduleName;
-					$$moduleName = new $className(); // Instantiate the Module, all init procedure must reside in __constructor() method
-					$$moduleName->setTsStartLoad();
-				}
-			}
-		}
+		(new ApplicationComposition($path))->boot();
 	}
 	}
 
 
 	/**
 	/**
@@ -134,51 +108,70 @@ class OMUtils
 		return base64_encode(gzcompress($data));
 		return base64_encode(gzcompress($data));
 	}
 	}
 
 
-	/*
-	 * Exit from APP if the OMLINK requirement aren't meet
+	/**
+	 * Stop startup when the PHP 8.5 runtime requirement is not met.
 	 */
 	 */
-
-	public function DieByOldPhpVersion()
+	public function RequirePhp85(): void
 	{
 	{
-		if (version_compare(PHP_VERSION, '7.2') != 1)
-			throw new Exception('PHP 7.2+ is need to run the project');
+		if (version_compare(PHP_VERSION, '8.5', '<')) {
+			throw new Exception('PHP 8.5+ is required to run the project');
+		}
 	}
 	}
 
 
 	/**
 	/**
 	 * Check if a port server is opened, return $errno code of fsockopen function
 	 * Check if a port server is opened, return $errno code of fsockopen function
 	 *
 	 *
-	 * @param string $ip
-	 * @param int $port
-	 * @param int $timeout_sec
-	 * @return int
+	 * @param string $ip Server IP address.
+	 * @param int $port Server port.
+	 * @param int $timeoutSeconds Connection timeout in seconds.
+	 * @return int Socket error code; 0 when the server is reachable.
+	 * @sideEffects Attempts a socket connection.
 	 */
 	 */
-	function isServerAlive(string $ip, int $port, int $timeout_sec = 5): int
-	{
-		try {
-			@fclose($sck = @fsockopen($ip, $port, $errno, $errstr, $timeout_sec));
-		} catch (Exception $e) {
-			die($e->getMessage());
+	public function isServerAlive(
+		string $ip,
+		int $port,
+		int $timeoutSeconds = self::DEFAULT_SOCKET_TIMEOUT_SECONDS,
+	): int {
+		$socket = fsockopen($ip, $port, $errorCode, $errorMessage, $timeoutSeconds);
+		if ($socket === false) {
+			return $errorCode;
 		}
 		}
-		return $errno;
+
+		if (!fclose($socket)) {
+			throw new RuntimeException("Unable to close socket for {$ip}:{$port}");
+		}
+
+		return 0;
 	}
 	}
 
 
 	/**
 	/**
-	 * Recurse delete folder
+	 * Recursively removes a directory or file.
 	 *
 	 *
-	 * @param string $dir /path/for/the/directory/
+	 * @param string $path Path to remove.
+	 * @return void
+	 * @sideEffects Deletes files and directories.
+	 * @throws RuntimeException When a path cannot be removed.
 	 */
 	 */
-	function rrmdir($dir)
+	public function rrmdir(string $path): void
 	{
 	{
-		if (is_dir($dir)) {
-			$files = glob($dir . '*', GLOB_MARK); //GLOB_MARK adds a slash to directories returned
+		if (is_dir($path)) {
+			$files = glob($path . '*', GLOB_MARK);
+			if ($files === false) {
+				throw new RuntimeException("Unable to read directory {$path}");
+			}
 
 
-			foreach($files as $file) {
+			foreach ($files as $file) {
 				$this->rrmdir($file);
 				$this->rrmdir($file);
 			}
 			}
 
 
-			@rmdir($dir);
-		} elseif (is_file($dir)) {
-			@unlink($dir);
+			if (!rmdir($path)) {
+				throw new RuntimeException("Unable to remove directory {$path}");
+			}
+			return;
+		}
+
+		if (is_file($path) && !unlink($path)) {
+			throw new RuntimeException("Unable to remove file {$path}");
 		}
 		}
 	}
 	}
 }
 }

+ 52 - 20
src/OpenMind/System/PluginStrategy.php

@@ -1,9 +1,4 @@
 <?php
 <?php
-/**
- * @author Giancarlo Voci
- * @date 21/06/2018
- * @time 12:34
- */
 
 
 namespace OpenMind\System;
 namespace OpenMind\System;
 
 
@@ -17,6 +12,9 @@ use Exception;
  */
  */
 interface IPluginStrategy
 interface IPluginStrategy
 {
 {
+	/**
+	 * Initialize the plugin.
+	 */
 	public function __construct();
 	public function __construct();
 
 
 	/**
 	/**
@@ -49,47 +47,72 @@ interface IPluginStrategy
  */
  */
 class PluginStrategy implements IPluginStrategy
 class PluginStrategy implements IPluginStrategy
 {
 {
-	static private $timeLoadStart = null;
+	private const DEBUG_LOG_LEVEL = 2;
+	private const LOAD_LOG_DATE_FORMAT = 'd/m/Y H:i:s';
+	private const DEFAULT_VERSION = '1.0';
+	private const DEFAULT_MENU_ORDER = 99999;
+
+	private static $timeLoadStart = null;
 
 
+	/**
+	 * Reject direct instantiation of the base plugin strategy.
+	 *
+	 * @throws Exception When the base strategy is instantiated directly.
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		throw new Exception("Plugin " . $this->getName() . ' doesn\'t have a __constructor() function. Please provide one.');
 		throw new Exception("Plugin " . $this->getName() . ' doesn\'t have a __constructor() function. Please provide one.');
 	}
 	}
 
 
 	/**
 	/**
-	 * Return Class name, useful when a list of installed plugins is need
+	 * Return the class name for lists of installed plugins.
 	 *
 	 *
-	 * @return string
+	 * @return string Plugin class name.
 	 */
 	 */
 	public function getName(): string
 	public function getName(): string
 	{
 	{
 		return get_class($this);
 		return get_class($this);
 	}
 	}
 
 
-	static function setTsStartLoad()
+	/**
+	 * Record the start time of the current plugin load.
+	 *
+	 * @return void
+	 * @side-effects Updates the shared plugin load timestamp.
+	 */
+	public static function setTsStartLoad()
 	{
 	{
 		self::$timeLoadStart = round(microtime(true) * 1000);
 		self::$timeLoadStart = round(microtime(true) * 1000);
 	}
 	}
 
 
+	/**
+	 * Log plugin load duration when debug logging is enabled.
+	 *
+	 * @return void
+	 * @side-effects Writes a load-duration entry to the framework logger.
+	 */
 	public function __destruct()
 	public function __destruct()
 	{
 	{
 		global $f3, $frameworkUtils;
 		global $f3, $frameworkUtils;
-		if ($f3->get('DEBUG') > 2) {
+		if ($f3->get('DEBUG') > self::DEBUG_LOG_LEVEL) {
 			$timeEnd = round(microtime(true) * 1000);
 			$timeEnd = round(microtime(true) * 1000);
-			$totalMS = $timeEnd - self::$timeLoadStart;
+			$totalMilliseconds = $timeEnd - self::$timeLoadStart;
 			$className = get_called_class();
 			$className = get_called_class();
-			$frameworkUtils->getLogger()->write("Class $className loaded in {$totalMS}ms", 'd/m/Y H:i:s');
+			$frameworkUtils->getLogger()->write(
+				"Class $className loaded in {$totalMilliseconds}ms",
+				self::LOAD_LOG_DATE_FORMAT,
+			);
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Return default version for plugins that doesn't have one
+	 * Return the default version for plugins that do not define one.
 	 *
 	 *
-	 * @return string
+	 * @return string Default plugin version.
 	 */
 	 */
 	public function getVersion(): string
 	public function getVersion(): string
 	{
 	{
-		return '1.0';
+		return self::DEFAULT_VERSION;
 	}
 	}
 
 
 	/**
 	/**
@@ -97,11 +120,15 @@ class PluginStrategy implements IPluginStrategy
 	 *
 	 *
 	 * @param array|string $pattern
 	 * @param array|string $pattern
 	 * @param string $handler
 	 * @param string $handler
+	 * @return void
+	 * @side-effects Adds a route to the framework route chain.
 	 */
 	 */
 	public function AddRoute($pattern, string $handler)
 	public function AddRoute($pattern, string $handler)
 	{
 	{
 		global $f3;
 		global $f3;
-		$f3->route($pattern, $handler, 0);  // TTL 60s
+
+		// The route priority is zero, matching the legacy registration behavior.
+		$f3->route($pattern, $handler, 0);
 	}
 	}
 
 
 	/**
 	/**
@@ -116,6 +143,9 @@ class PluginStrategy implements IPluginStrategy
 	 * &nbsp;&nbsp;'url' => '/bind',<br/>
 	 * &nbsp;&nbsp;'url' => '/bind',<br/>
 	 * &nbsp;&nbsp;'isDisabled' => false<br/>
 	 * &nbsp;&nbsp;'isDisabled' => false<br/>
 	 * ]
 	 * ]
+	 *
+	 * @return void
+	 * @side-effects Updates the global menu when permission requirements are met.
 	 */
 	 */
 	public function AddMenuItem(string $menuName, array $options)
 	public function AddMenuItem(string $menuName, array $options)
 	{
 	{
@@ -125,8 +155,10 @@ class PluginStrategy implements IPluginStrategy
 			'url' => '',
 			'url' => '',
 			'isDisabled' => false,
 			'isDisabled' => false,
 			'image' => '',
 			'image' => '',
+			'imgClass' => '',
+			'mainMenu' => null,
 			'permissionName' => null,
 			'permissionName' => null,
-			'order' => 99999
+			'order' => self::DEFAULT_MENU_ORDER,
 		];
 		];
 
 
 		$newMenu = array_merge($defaults, $options);
 		$newMenu = array_merge($defaults, $options);
@@ -134,12 +166,12 @@ class PluginStrategy implements IPluginStrategy
 		$globalMenuUpdated = $f3->get('GLOBALMENU');
 		$globalMenuUpdated = $f3->get('GLOBALMENU');
 		$globalMenuUpdated[$menuName] = $newMenu;
 		$globalMenuUpdated[$menuName] = $newMenu;
 
 
-		// If user logged check permission
+		// Check permissions for logged-in users before publishing the menu.
 		$user = User::Restore();
 		$user = User::Restore();
 		if (!$user->dry()) {
 		if (!$user->dry()) {
-			$hasMenuPermissionRequirements = !is_null($newMenu['permissionName']);
+			$hasMenuPermissionRequirements = $newMenu['permissionName'] !== null;
 
 
-			if (!$hasMenuPermissionRequirements || ($hasMenuPermissionRequirements && $user->hasPermission($newMenu['permissionName']))) {
+			if (!$hasMenuPermissionRequirements || $user->hasPermission($newMenu['permissionName'])) {
 				$f3->set('GLOBALMENU', $globalMenuUpdated);
 				$f3->set('GLOBALMENU', $globalMenuUpdated);
 			}
 			}
 		}
 		}

+ 116 - 57
src/OpenMind/System/System.php

@@ -1,9 +1,4 @@
 <?php
 <?php
-/**
- * @author Giancarlo Voci
- * @date 21/06/2018
- * @time 18:28
- */
 
 
 namespace OpenMind\System;
 namespace OpenMind\System;
 
 
@@ -28,42 +23,73 @@ use ReflectionException;
  */
  */
 abstract class Enum
 abstract class Enum
 {
 {
+	/** @var array<string, string> */
+	private array $propertyNames = [];
+
+	/**
+	 * Initialize enum values and legacy name lookups for the concrete enum.
+	 *
+	 * @side-effects Assigns enum property values and populates name lookups.
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		$counter = 0;
 		$counter = 0;
-		foreach((new ReflectionClass($this))->getProperties() as $property) {
+		foreach ($this->enumProperties() as $property) {
 			$propertyValue = $property->getValue($this);
 			$propertyValue = $property->getValue($this);
 
 
-			switch (gettype($propertyValue)) {
-				case 'integer':
-					$counter = $propertyValue + 1;
-					break;
-				case 'NULL':
-					$propertyValue = $counter++;
-					break;
+			if (is_int($propertyValue)) {
+				$counter = $propertyValue + 1;
+			} elseif ($propertyValue === null) {
+				$propertyValue = $counter++;
 			}
 			}
+
 			$property->setValue($this, $propertyValue);
 			$property->setValue($this, $propertyValue);
-			$this->{"{$property->name}ToString"} = $property->name;
+			$this->propertyNames["{$property->getName()}ToString"] = $property->getName();
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Get the variable name as string by its value
+	 * Preserve legacy *ToString reads without creating dynamic properties.
+	 *
+	 * @param string $name Legacy property name to resolve.
+	 * @return string|null Matching enum property name, when defined.
+	 */
+	public function __get(string $name): ?string
+	{
+		return $this->propertyNames[$name] ?? null;
+	}
+
+	/**
+	 * Get the variable name as string by its value.
 	 *
 	 *
-	 * @param $value
-	 * @return string|null the variable name
+	 * @param mixed $value Enum value to resolve.
+	 * @return string|null Matching enum property name, when defined.
 	 * @throws ReflectionException
 	 * @throws ReflectionException
 	 */
 	 */
-	public function getNameByValue($value): ?string
+	public function getNameByValue(mixed $value): ?string
 	{
 	{
-		foreach((new ReflectionClass($this))->getProperties() as $property) {
+		foreach ($this->enumProperties() as $property) {
 			if ($property->getValue($this) === $value) {
 			if ($property->getValue($this) === $value) {
-				return $property->name;
+				return $property->getName();
 			}
 			}
 		}
 		}
 
 
 		return null;
 		return null;
 	}
 	}
+
+	/**
+	 * Return the concrete enum's reflected properties.
+	 *
+	 * @return list<\ReflectionProperty> Properties declared by the concrete enum.
+	 * @throws ReflectionException
+	 */
+	private function enumProperties(): array
+	{
+		return array_values(array_filter(
+			(new ReflectionClass($this))->getProperties(),
+			static fn ($property): bool => $property->getDeclaringClass()->getName() !== self::class,
+		));
+	}
 }
 }
 
 
 class System extends PluginStrategy
 class System extends PluginStrategy
@@ -84,10 +110,17 @@ class System extends PluginStrategy
 	 */
 	 */
 	private $sharedCache;
 	private $sharedCache;
 
 
+	/**
+	 * Configure framework error handling, caches, database access, and routes.
+	 *
+	 * @side-effects Updates framework globals, initializes the database connection, and
+	 * terminates with the database error message when connection setup fails.
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		global $f3;
 		global $f3;
-		// Set Error Handler
+
+		// Set the framework error handler.
 		$f3->set('ONERROR', __NAMESPACE__ . '\ErrorHandlers->Handler');
 		$f3->set('ONERROR', __NAMESPACE__ . '\ErrorHandlers->Handler');
 
 
 		$this->cache = Cache::instance();
 		$this->cache = Cache::instance();
@@ -95,23 +128,28 @@ class System extends PluginStrategy
 		$this->sharedCache = new Cache();
 		$this->sharedCache = new Cache();
 		$this->sharedCache->load($f3->get('CACHE'), 'OMLINK_SHARED_CACHE');
 		$this->sharedCache->load($f3->get('CACHE'), 'OMLINK_SHARED_CACHE');
 
 
-		// Set Database connection
+		// Configure the database connection.
 		try {
 		try {
-			$dbMode = (strtolower(PHP_OS_FAMILY) == 'linux') ? TRUE : FALSE;
-			if ($f3->get('CLI')) // from cli, always persistent mode
-				$dbMode = TRUE;
+			$dbMode = strtolower(PHP_OS_FAMILY) === 'linux';
+			if ($f3->get('CLI')) {
+				// CLI requests always use persistent connections.
+				$dbMode = true;
+			}
 
 
 			$db = new SQL(
 			$db = new SQL(
 				$f3->get('DSN'),
 				$f3->get('DSN'),
 				$f3->get('dbuser'),
 				$f3->get('dbuser'),
 				$f3->get('dbpass'),
 				$f3->get('dbpass'),
-				array(
+				[
 					PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 					PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
-					PDO::ATTR_PERSISTENT => $dbMode  // To avoid massive new persistent connections due to php-cgi processes
-				));
 
 
-			// This fix memory leak in ->exec() and all methods of F3 that use this function like load, find, findone etc...
-			$db->log(FALSE);
+					// Avoid excessive persistent connections from PHP-CGI processes.
+					PDO::ATTR_PERSISTENT => $dbMode,
+				],
+			);
+
+			// Prevent the memory leak in exec() and F3 methods that use it.
+			$db->log(false);
 
 
 			$f3->set('DB', $db);
 			$f3->set('DB', $db);
 		} catch (PDOException $e) {
 		} catch (PDOException $e) {
@@ -119,9 +157,14 @@ class System extends PluginStrategy
 		}
 		}
 
 
 		$this->setSystemRoutes();
 		$this->setSystemRoutes();
-
 	}
 	}
 
 
+	/**
+	 * Register routes owned by the system module.
+	 *
+	 * @return void
+	 * @side-effects Adds system routes to the framework route chain.
+	 */
 	private function setSystemRoutes()
 	private function setSystemRoutes()
 	{
 	{
 		$this->AddRoute('GET /api.system.getserverstatus', __NAMESPACE__ . '\System->getServerStatus');
 		$this->AddRoute('GET /api.system.getserverstatus', __NAMESPACE__ . '\System->getServerStatus');
@@ -129,81 +172,97 @@ class System extends PluginStrategy
 	}
 	}
 
 
 	/**
 	/**
-	 * Get cache cache
+	 * Get the client-related session cache.
 	 *
 	 *
-	 * @return Cache
+	 * @return Cache Client-related cache instance.
 	 */
 	 */
 	public function getCache(): Cache
 	public function getCache(): Cache
 	{
 	{
 		return $this->cache;
 		return $this->cache;
 	}
 	}
 
 
+	/**
+	 * Get the server-related shared cache.
+	 *
+	 * @return Cache Shared cache instance.
+	 */
 	public function getSharedCache(): Cache
 	public function getSharedCache(): Cache
 	{
 	{
 		return $this->sharedCache;
 		return $this->sharedCache;
 	}
 	}
 
 
 	/**
 	/**
-	 * AJAX web service for bell notifications and system checking
+	 * Return notification data for the bell and system status checks.
 	 *
 	 *
-	 * @param Base $f3
+	 * @param Base $f3 Framework instance provided by the route handler.
+	 * @return void
+	 * @side-effects Writes JSON response data.
 	 */
 	 */
 	public function getServerStatus($f3)
 	public function getServerStatus($f3)
 	{
 	{
-		$qs = new QueueSystem();
-		if (!$qs->send('')) {
-			$omlNotifications = new Notifications();
-			$omlNotifications->notify(Notifications::FB_NOTALIVE, 'Errore: Servizio di FindBox non disponibile');
-		} else {
-			$omlNotifications = new Notifications();
-			$omlNotifications->Remove(Notifications::FB_NOTALIVE);
-		}
-
 		$ret = [];
 		$ret = [];
 		$notifications = new Notifications();
 		$notifications = new Notifications();
 		$notifications->count = 'count(caption)';
 		$notifications->count = 'count(caption)';
-		$sqlGroupBy = array(
+		$sqlGroupBy = [
 			'order' => 'timestamp DESC',
 			'order' => 'timestamp DESC',
-			'group' => 'caption'
+			'group' => 'caption',
+		];
+		$ret['notify'] = array_map(
+			[$notifications, 'cast'],
+			$notifications->find(null, $sqlGroupBy),
 		);
 		);
-		$ret['notify'] = array_map(array($notifications, 'cast'), $notifications->find(NULL, $sqlGroupBy));
 
 
 		echo json_encode($ret);
 		echo json_encode($ret);
 	}
 	}
 
 
+	/**
+	 * Delete one notification or clear all notifications.
+	 *
+	 * @param Base $f3 Framework instance provided by the route handler.
+	 * @param array $args Route arguments containing the notification code.
+	 * @return void
+	 * @side-effects Deletes notification records.
+	 */
 	public function NotificationDelete($f3, $args)
 	public function NotificationDelete($f3, $args)
 	{
 	{
-		if ($args['code'] != 0)
+		if ($args['code'] != 0) {
 			(new Notifications())->Remove($args['code']);
 			(new Notifications())->Remove($args['code']);
-		else
+		} else {
 			(new Notifications())->emptyAll();
 			(new Notifications())->emptyAll();
-
+		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Store in F3 cache the session id, usefull for CLI call
+	 * Store the session ID in the F3 cache for CLI calls.
 	 *
 	 *
-	 * @param string $cacheName
+	 * @param string $cacheName Cache key used to store the session ID.
+	 * @return void
+	 * @side-effects Writes the current session ID to the cache.
 	 * @see System::RestoreWebSession()
 	 * @see System::RestoreWebSession()
 	 */
 	 */
-	public Function StoreWebSession(string $cacheName = 'WEBSESSIONID')
+	public function StoreWebSession(string $cacheName = 'WEBSESSIONID')
 	{
 	{
 		$this->cache->set($cacheName, session_id());
 		$this->cache->set($cacheName, session_id());
 	}
 	}
 
 
 	/**
 	/**
-	 * Restore the web session for CLI calls
+	 * Restore the web session for CLI calls.
 	 *
 	 *
-	 * @param string $cacheName
+	 * @param string $cacheName Cache key containing the session ID.
+	 * @return void
+	 * @side-effects Closes the current session and starts the cached session.
 	 * @see System::StoreWebSession()
 	 * @see System::StoreWebSession()
 	 */
 	 */
 	public function RestoreWebSession(string $cacheName = 'WEBSESSIONID')
 	public function RestoreWebSession(string $cacheName = 'WEBSESSIONID')
 	{
 	{
 		if ($this->cache->get($cacheName)) {
 		if ($this->cache->get($cacheName)) {
 			$id = $this->cache->get($cacheName);
 			$id = $this->cache->get($cacheName);
-			if (isset($_SESSION)) {  // if there is already a session running
-				session_write_close(); // save and close it
+
+			if (isset($_SESSION)) {
+				// Close an already-running session before switching IDs.
+				session_write_close();
 			}
 			}
+
 			session_id($id);
 			session_id($id);
 			session_start();
 			session_start();
 		}
 		}

+ 55 - 17
src/OpenMind/System/User.php

@@ -21,6 +21,8 @@ use DB\SQL\Mapper;
  */
  */
 class User extends Mapper
 class User extends Mapper
 {
 {
+	public const PASSWORD_SEPARATOR = '|';
+
 	private $userRoles;
 	private $userRoles;
 
 
 	/**
 	/**
@@ -32,7 +34,8 @@ class User extends Mapper
 		global $f3;
 		global $f3;
 
 
 		$this->onload(function ($obj) {
 		$this->onload(function ($obj) {
-			$obj->userRoles = unserialize($obj->roles);
+			$roles = unserialize($obj->roles, ['allowed_classes' => false]);
+			$obj->userRoles = is_array($roles) ? $roles : [];
 		});
 		});
 
 
 		parent::__construct($f3->get('DB'), 'users', NULL, 3600);
 		parent::__construct($f3->get('DB'), 'users', NULL, 3600);
@@ -46,7 +49,7 @@ class User extends Mapper
 	/**
 	/**
 	 * Reload user from $_SESSION
 	 * Reload user from $_SESSION
 	 */
 	 */
-	public static function Restore()
+	public static function Restore(): self
 	{
 	{
 		global $f3;
 		global $f3;
 
 
@@ -55,15 +58,22 @@ class User extends Mapper
 
 
 	}
 	}
 
 
-	public function generatePassword(string $clearPassword)
+	/**
+	 * Hashes a password for the current user.
+	 *
+	 * @param string $clearPassword Password provided by the user.
+	 * @return string Password hash.
+	 * @sideEffects None.
+	 */
+	public function generatePassword(string $clearPassword): string
 	{
 	{
-		return password_hash($this->username . "|$clearPassword", PASSWORD_BCRYPT);
+		return password_hash($this->username . self::PASSWORD_SEPARATOR . $clearPassword, PASSWORD_BCRYPT);
 	}
 	}
 
 
 	/**
 	/**
 	 * Save user in $_SESSION
 	 * Save user in $_SESSION
 	 */
 	 */
-	public function Store()
+	public function Store(): void
 	{
 	{
 		global $f3;
 		global $f3;
 		$f3->set('SESSION.user', $this->id);
 		$f3->set('SESSION.user', $this->id);
@@ -77,31 +87,56 @@ class User extends Mapper
 	 */
 	 */
 	public function hasPermission(string $permissionName): bool
 	public function hasPermission(string $permissionName): bool
 	{
 	{
-		if ($this->userRoles)
-			return in_array($permissionName, $this->userRoles);
-		else
-			return false;
+		return in_array($permissionName, $this->userRoles, true);
 	}
 	}
 
 
-	public function grantPermission(string $permissionName)
+	/**
+	 * Grants a role to the current user if it is not already assigned.
+	 *
+	 * @param string $permissionName Role to grant.
+	 * @return void
+	 * @sideEffects Updates the in-memory role list.
+	 */
+	public function grantPermission(string $permissionName): void
 	{
 	{
-		if (!in_array($permissionName, $this->userRoles))
-			array_push($this->userRoles, $permissionName);
+		if (!in_array($permissionName, $this->userRoles, true)) {
+			$this->userRoles[] = $permissionName;
+		}
 	}
 	}
 
 
-	public function removePermission(string $permissionName)
+	/**
+	 * Removes a role from the current user.
+	 *
+	 * @param string $permissionName Role to remove.
+	 * @return void
+	 * @sideEffects Updates the in-memory role list.
+	 */
+	public function removePermission(string $permissionName): void
 	{
 	{
-		$i = array_search($permissionName, $this->userRoles);
-		if ($i !== false)
-			unset($this->userRoles[$i]);
+		$roleIndex = array_search($permissionName, $this->userRoles, true);
+		if ($roleIndex !== false) {
+			unset($this->userRoles[$roleIndex]);
+		}
 	}
 	}
 
 
+	/**
+	 * Serializes the in-memory roles before saving the user.
+	 *
+	 * @return mixed Mapper save result.
+	 * @sideEffects Updates the roles database field.
+	 */
 	public function save()
 	public function save()
 	{
 	{
 		$this->roles = serialize($this->userRoles);
 		$this->roles = serialize($this->userRoles);
 		return parent::save();
 		return parent::save();
 	}
 	}
 
 
+	/**
+	 * Clears the in-memory roles and resets the mapper state.
+	 *
+	 * @return void
+	 * @sideEffects Clears user state.
+	 */
 	public function reset()
 	public function reset()
 	{
 	{
 		$this->userRoles = [];
 		$this->userRoles = [];
@@ -109,7 +144,10 @@ class User extends Mapper
 	}
 	}
 
 
 	/**
 	/**
-	 * @return array
+	 * Returns the roles assigned to the user.
+	 *
+	 * @return array Assigned roles.
+	 * @sideEffects None.
 	 */
 	 */
 	public function getRoles(): array
 	public function getRoles(): array
 	{
 	{

+ 30 - 27
src/OpenMind/System/UserRoles.php

@@ -1,65 +1,68 @@
 <?php
 <?php
-/**
- * Created by PhpStorm.
- * User: Giancarlo
- * Date: 01/08/2018
- * Time: 15:52
- */
 
 
 namespace OpenMind\System;
 namespace OpenMind\System;
 
 
-
 /**
 /**
- * Global User Roles: it's a volatile list of user roles present in all application
- * It's not linked to users.
- *
- * Class UserRoles
- * @package OpenMind\System
+ * Global user roles: a volatile list of roles present throughout the application.
+ * The list is not linked to users.
  */
  */
 class UserRoles
 class UserRoles
 {
 {
+	private const F3_KEY = 'USERROLES';
+
+	/**
+	 * Initialize the global user-role list when it does not exist.
+	 *
+	 * @side-effects Creates the global user-role list when needed.
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		global $f3;
 		global $f3;
-		// Define global array is doesn't exists
-		if (is_null($f3->get('USERROLES')))
-			$f3->set('USERROLES', array());
+
+		if ($f3->get(self::F3_KEY) === null) {
+			$f3->set(self::F3_KEY, []);
+		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Get all defined user roles
+	 * Get all defined user roles.
 	 *
 	 *
-	 * @return array
+	 * @return array All configured user roles.
+	 * @side-effects Reads the global user-role list.
 	 */
 	 */
 	public static function getAllRoles(): array
 	public static function getAllRoles(): array
 	{
 	{
 		global $f3;
 		global $f3;
-		return $f3->get('USERROLES');
+
+		return $f3->get(self::F3_KEY);
 	}
 	}
 
 
 	/**
 	/**
-	 * Retrieve permission value
+	 * Retrieve the value for a permission.
 	 *
 	 *
-	 * @param string $permissionName
-	 * @return mixed
+	 * @param string $permissionName Permission name to retrieve.
+	 * @return mixed Configured permission value.
+	 * @side-effects Reads the global user-role list.
 	 */
 	 */
 	public function get(string $permissionName)
 	public function get(string $permissionName)
 	{
 	{
 		global $f3;
 		global $f3;
-		return $f3->get('USERROLES')[$permissionName];
+
+		return $f3->get(self::F3_KEY)[$permissionName];
 	}
 	}
 
 
 	/**
 	/**
-	 * Add new permission to roles
+	 * Add a new permission to the roles list.
 	 *
 	 *
-	 * @param string $permissionName
+	 * @param string $permissionName Permission name to add.
+	 * @return void
+	 * @side-effects Mutates the global user-role list.
 	 */
 	 */
 	public function add(string $permissionName)
 	public function add(string $permissionName)
 	{
 	{
 		global $f3;
 		global $f3;
 
 
-		$roles = &$f3->ref('USERROLES');
-
-		array_push($roles, $permissionName);
+		$roles = &$f3->ref(self::F3_KEY);
+		$roles[] = $permissionName;
 	}
 	}
 }
 }

+ 23 - 14
src/OpenMind/Web/Controller.php

@@ -24,8 +24,11 @@ class Controller
 	private $sanitizedPostRequest;
 	private $sanitizedPostRequest;
 
 
 	/**
 	/**
-	 * Controller constructor.
-	 * @param Base $f3
+	 * Initializes request state and enforces route permissions.
+	 *
+	 * @param Base $f3 F3 application instance.
+	 * @return void
+	 * @sideEffects Sanitizes POST data, sets the current user, and may redirect.
 	 */
 	 */
 	public function __construct($f3)
 	public function __construct($f3)
 	{
 	{
@@ -49,32 +52,36 @@ class Controller
 		}
 		}
 
 
 		if (
 		if (
-			$loggedUser &&  // user is logged in?
-			$currentAlias && // the page require a permission?
-			!$loggedUser->hasPermission($currentAlias) // The user has the permission to go next step?
+			$loggedUser
+			&& $currentAlias
+			&& !$loggedUser->hasPermission($currentAlias)
 		) {
 		) {
 			$f3->reroute('/homemenu');
 			$f3->reroute('/homemenu');
 			return;
 			return;
 		}
 		}
-
-		return;
 	}
 	}
 
 
+	/**
+	 * Logs the controller load time when debug logging is enabled.
+	 *
+	 * @return void
+	 * @sideEffects Writes a load-time entry to the application logger.
+	 */
 	public function __destruct()
 	public function __destruct()
 	{
 	{
 		global $f3, $frameworkUtils;
 		global $f3, $frameworkUtils;
 		if ($f3->get('DEBUG') > 2) {
 		if ($f3->get('DEBUG') > 2) {
 			$timeEnd = round(microtime(true) * 1000);
 			$timeEnd = round(microtime(true) * 1000);
-			$totalMS = $timeEnd - $this->timeLoadStart;
+			$totalMilliseconds = $timeEnd - $this->timeLoadStart;
 			$className = get_called_class();
 			$className = get_called_class();
-			$frameworkUtils->getLogger()->write("Page $className loaded in {$totalMS}ms", 'd/m/Y H:i:s');
+			$frameworkUtils->getLogger()->write("Page $className loaded in {$totalMilliseconds}ms", 'd/m/Y H:i:s');
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Return the sanitized POST parameters
+	 * Returns the sanitized POST parameters.
 	 *
 	 *
-	 * @return mixed
+	 * @return mixed Sanitized POST data.
 	 */
 	 */
 	protected function getSanitizedPostRequest()
 	protected function getSanitizedPostRequest()
 	{
 	{
@@ -82,10 +89,12 @@ class Controller
 	}
 	}
 
 
 	/**
 	/**
-	 * Use setTimeout to request a change url
+	 * Stores client-side redirect instructions in the F3 hive.
 	 *
 	 *
-	 * @param string $url to change to
-	 * @param int $msDelay
+	 * @param string $url URL to change to.
+	 * @param int $msDelay Delay before changing the URL in milliseconds.
+	 * @return void
+	 * @sideEffects Updates F3 redirect variables.
 	 */
 	 */
 	protected function setRedirect(string $url, int $msDelay)
 	protected function setRedirect(string $url, int $msDelay)
 	{
 	{

+ 4 - 5
src/OpenMind/Web/Login.php

@@ -68,17 +68,16 @@ class Login extends Controller
 	 *
 	 *
 	 * @param string $username
 	 * @param string $username
 	 * @param string $password
 	 * @param string $password
-	 * @return int|User
+	 * @return User|null Authenticated user, if credentials are valid.
 	 */
 	 */
 	private function checkLogin(string $username, string $password): ?User
 	private function checkLogin(string $username, string $password): ?User
 	{
 	{
 		$user = new User(['username=?', $username]);
 		$user = new User(['username=?', $username]);
 
 
-		if (!$user->dry()) {
-			$exist = password_verify("{$username}|{$password}", $user->saltedpassword);
-			if ($exist)
-				return $user;
+		if (!$user->dry() && password_verify($username . User::PASSWORD_SEPARATOR . $password, $user->saltedpassword)) {
+			return $user;
 		}
 		}
+
 		return null;
 		return null;
 	}
 	}
 
 

+ 10 - 1
src/OpenMind/Web/Web.php

@@ -14,7 +14,11 @@ use Template;
 class Pages extends Controller
 class Pages extends Controller
 {
 {
 	/**
 	/**
-	 * @param Base $f3
+	 * Sorts the global menu and renders the home menu page.
+	 *
+	 * @param Base $f3 F3 application instance.
+	 * @return void
+	 * @sideEffects Sorts the global menu and writes the rendered page.
 	 */
 	 */
 	public function HomeMenu($f3)
 	public function HomeMenu($f3)
 	{
 	{
@@ -30,6 +34,11 @@ class Pages extends Controller
 
 
 class Web extends PluginStrategy
 class Web extends PluginStrategy
 {
 {
+	/**
+	 * Registers the authentication and home-page routes.
+	 *
+	 * @return void
+	 */
 	public function __construct()
 	public function __construct()
 	{
 	{
 		// Services
 		// Services

+ 12 - 12
ui/admin/assets/css/style.css

@@ -918,7 +918,7 @@ select[multiple] {
 	background-color: #f4f8fb;
 	background-color: #f4f8fb;
 }
 }
 
 
-.nav .open > a, .nav .open > a:focus, .nav .open > a:hover {
+.nav .show > a, .nav .show > a:focus, .nav .show > a:hover {
 	border-color: #eeeeee;
 	border-color: #eeeeee;
 }
 }
 
 
@@ -1423,7 +1423,7 @@ pre {
 	margin-left: 0;
 	margin-left: 0;
 }
 }
 
 
-.btn-group.open .dropdown-toggle {
+.btn-group.show .dropdown-toggle {
 	-webkit-box-shadow: 0 0 0 100px rgba(0, 0, 0, 0.1) inset;
 	-webkit-box-shadow: 0 0 0 100px rgba(0, 0, 0, 0.1) inset;
 	box-shadow: 0 0 0 100px rgba(0, 0, 0, 0.1) inset;
 	box-shadow: 0 0 0 100px rgba(0, 0, 0, 0.1) inset;
 }
 }
@@ -1439,7 +1439,7 @@ pre {
 
 
 .btn-custom:hover, .btn-custom:focus, .btn-custom:active, .btn-custom.active,
 .btn-custom:hover, .btn-custom:focus, .btn-custom:active, .btn-custom.active,
 .btn-custom.focus, .btn-custom:active, .btn-custom:focus, .btn-custom:hover,
 .btn-custom.focus, .btn-custom:active, .btn-custom:focus, .btn-custom:hover,
-.open > .dropdown-toggle.btn-custom {
+.show > .dropdown-toggle.btn-custom {
 	background-color: #59a9f8 !important;
 	background-color: #59a9f8 !important;
 	border: 1px solid #59a9f8 !important;
 	border: 1px solid #59a9f8 !important;
 }
 }
@@ -1451,7 +1451,7 @@ pre {
 
 
 .btn-primary:hover, .btn-primary:focus, .btn-primary:active, .btn-primary.active,
 .btn-primary:hover, .btn-primary:focus, .btn-primary:active, .btn-primary.active,
 .btn-primary.focus, .btn-primary:active, .btn-primary:focus, .btn-primary:hover,
 .btn-primary.focus, .btn-primary:active, .btn-primary:focus, .btn-primary:hover,
-.open > .dropdown-toggle.btn-primary {
+.show > .dropdown-toggle.btn-primary {
 	background-color: #167ccb !important;
 	background-color: #167ccb !important;
 	border: 1px solid #167ccb !important;
 	border: 1px solid #167ccb !important;
 }
 }
@@ -1461,7 +1461,7 @@ pre {
 	border: 1px solid #10c469 !important;
 	border: 1px solid #10c469 !important;
 }
 }
 
 
-.btn-success:hover, .btn-success:focus, .btn-success:active, .btn-success.active, .btn-success.focus, .btn-success:active, .btn-success:focus, .btn-success:hover, .open > .dropdown-toggle.btn-success {
+.btn-success:hover, .btn-success:focus, .btn-success:active, .btn-success.active, .btn-success.focus, .btn-success:active, .btn-success:focus, .btn-success:hover, .show > .dropdown-toggle.btn-success {
 	background-color: #0eac5c !important;
 	background-color: #0eac5c !important;
 	border: 1px solid #0eac5c !important;
 	border: 1px solid #0eac5c !important;
 }
 }
@@ -1471,7 +1471,7 @@ pre {
 	border: 1px solid #35b8e0 !important;
 	border: 1px solid #35b8e0 !important;
 }
 }
 
 
-.btn-info:hover, .btn-info:focus, .btn-info:active, .btn-info.active, .btn-info.focus, .btn-info:active, .btn-info:focus, .btn-info:hover, .open > .dropdown-toggle.btn-info {
+.btn-info:hover, .btn-info:focus, .btn-info:active, .btn-info.active, .btn-info.focus, .btn-info:active, .btn-info:focus, .btn-info:hover, .show > .dropdown-toggle.btn-info {
 	background-color: #21afda !important;
 	background-color: #21afda !important;
 	border: 1px solid #21afda !important;
 	border: 1px solid #21afda !important;
 }
 }
@@ -1481,7 +1481,7 @@ pre {
 	border: 1px solid #f9c851 !important;
 	border: 1px solid #f9c851 !important;
 }
 }
 
 
-.btn-warning:hover, .btn-warning:focus, .btn-warning:active, .btn-warning.active, .btn-warning.focus, .btn-warning:active, .btn-warning:focus, .btn-warning:hover, .open > .dropdown-toggle.btn-warning {
+.btn-warning:hover, .btn-warning:focus, .btn-warning:active, .btn-warning.active, .btn-warning.focus, .btn-warning:active, .btn-warning:focus, .btn-warning:hover, .show > .dropdown-toggle.btn-warning {
 	background-color: #f8c038 !important;
 	background-color: #f8c038 !important;
 	border: 1px solid #f8c038 !important;
 	border: 1px solid #f8c038 !important;
 }
 }
@@ -1491,7 +1491,7 @@ pre {
 	border: 1px solid #ff5b5b !important;
 	border: 1px solid #ff5b5b !important;
 }
 }
 
 
-.btn-danger:active, .btn-danger:focus, .btn-danger:hover, .btn-danger.active, .btn-danger.focus, .btn-danger:active, .btn-danger:focus, .btn-danger:hover, .open > .dropdown-toggle.btn-danger {
+.btn-danger:active, .btn-danger:focus, .btn-danger:hover, .btn-danger.active, .btn-danger.focus, .btn-danger:active, .btn-danger:focus, .btn-danger:hover, .show > .dropdown-toggle.btn-danger {
 	background-color: #ff4242 !important;
 	background-color: #ff4242 !important;
 	border: 1px solid #ff4242 !important;
 	border: 1px solid #ff4242 !important;
 }
 }
@@ -1501,7 +1501,7 @@ pre {
 	border: 1px solid #3b3e47 !important;
 	border: 1px solid #3b3e47 !important;
 }
 }
 
 
-.btn-inverse:hover, .btn-inverse:focus, .btn-inverse:active, .btn-inverse.active, .btn-inverse.focus, .btn-inverse:active, .btn-inverse:focus, .btn-inverse:hover, .open > .dropdown-toggle.btn-inverse {
+.btn-inverse:hover, .btn-inverse:focus, .btn-inverse:active, .btn-inverse.active, .btn-inverse.focus, .btn-inverse:active, .btn-inverse:focus, .btn-inverse:hover, .show > .dropdown-toggle.btn-inverse {
 	background-color: #474a55 !important;
 	background-color: #474a55 !important;
 	border: 1px solid #474a55 !important;
 	border: 1px solid #474a55 !important;
 }
 }
@@ -6524,7 +6524,7 @@ File: Responsive
 }
 }
 
 
 @media (max-width: 767px) {
 @media (max-width: 767px) {
-	.navbar-nav .open .dropdown-menu {
+	.navbar-nav .show .dropdown-menu {
 		background-color: #ffffff;
 		background-color: #ffffff;
 		-webkit-box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
 		-webkit-box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
 		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
 		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
@@ -6533,7 +6533,7 @@ File: Responsive
 		right: 0;
 		right: 0;
 	}
 	}
 
 
-	.navbar-nav .open .dropdown-menu li {
+	.navbar-nav .show .dropdown-menu li {
 		display: block;
 		display: block;
 	}
 	}
 
 
@@ -6645,7 +6645,7 @@ File: Responsive
 		display: none;
 		display: none;
 	}
 	}
 
 
-	.navbar-nav .open .dropdown-menu {
+	.navbar-nav .show .dropdown-menu {
 		margin-right: -20px;
 		margin-right: -20px;
 	}
 	}
 
 

+ 1 - 53
ui/admin/assets/js/OMGUtils.js

@@ -1,53 +1 @@
-export const fetchHeaders = {
-	'Accept': 'text/html',
-	'Content-Type': 'text/html',
-	'X-Requested-With': 'XMLHttpRequest',
-	'Cache-Control': 'no-cache',
-	'Pragma': 'no-cache',
-	'Expires': '0'
-};
-
-/**
- * Make an AJAX request.
- *
- * If error, catch return a promise error object:
- *
- *  .catch(err => alert(`${err.url}\n\n${err.status} (${err.statusText})`));
- *
- * @param url URL of RESTful api to call
- * @param options the fetch() options
- * @returns Object {Promise, signal: AbortController}
- */
-export function AjaxReq(url, options) {
-	let fetchSignal = new AbortController();
-	let defaults = {
-		headers: fetchHeaders,
-		signal: fetchSignal.signal
-	};
-	// Extend default options
-	Object.assign(defaults, options);
-
-	let fetchPromise = fetch(url, defaults)
-		.then(resp => {
-			if (resp.status === 200)
-				return resp.json();
-			else
-				return Promise.reject(resp);
-		})
-		.catch(err => {
-			if (err.name === 'AbortError')
-				return;
-			if (err.url !== undefined)
-				toastr.error(err.url, `${err.status} (${err.statusText})`);
-			else
-				toastr.error(err);
-		});
-
-	// Add some properties to return value
-	return {
-		fetchPromise: () => fetchPromise,
-		abort: function () {
-			fetchSignal.abort()
-		}
-	};
-}
+export { AjaxReq, fetchHeaders } from '../../../shared/OMGUtils';

+ 91 - 97
ui/admin/assets/js/admin.js

@@ -1,131 +1,125 @@
-import {ModalEslDetails} from "./ModalEslDetails";
-import {ModalTemplateSelector} from "./ModalTemplateSelector";
-import * as OMGUtils from "./OMGUtils";
+import $ from '../../../shared/jquery.js';
+import toastr from 'toastr';
+import Waves from 'node-waves';
+import * as OMGUtils from './OMGUtils';
 
 
-let modalEslDetails;
-let modalTemplateSelector;
+window.toastr = toastr;
+window.LoadConfig = loadConfig;
+window.SaveConfig = saveConfig;
+window.ToggleSelectAll = toggleSelectAll;
 
 
-function SaveConfig() {
-	let config = $('#config_text').val();
-	OMGUtils.AjaxReq(`${BASEURL}/admin/config`, {
+const CONFIG_ENDPOINT = `${BASEURL}/admin/config`;
+const LOAD_CONFIGURATION_METHOD = 'loadconfig';
+const SAVE_CONFIGURATION_METHOD = 'saveconfig';
+
+/**
+ * Displays a request failure in the admin interface.
+ *
+ * @param {unknown} error Request error.
+ * @returns {void}
+ * @sideEffects Displays a toast notification.
+ */
+function showRequestError(error) {
+	const message = error instanceof Response
+		? `${error.status} (${error.statusText})`
+		: 'Unexpected request error';
+
+	console.error('Admin configuration request failed.', error);
+	toastr.error(message);
+}
+
+/**
+ * Saves the configuration currently displayed in the editor.
+ *
+ * @returns {void}
+ * @sideEffects Performs a network request and displays a toast notification.
+ */
+function saveConfig() {
+	OMGUtils.AjaxReq(CONFIG_ENDPOINT, {
 		method: 'POST',
 		method: 'POST',
-		body: JSON.stringify({
-			"method": "saveconfig",
-			"value": config
-		})
+		body: JSON.stringify({ method: SAVE_CONFIGURATION_METHOD, value: $('#config_text').val() }),
 	}).fetchPromise()
 	}).fetchPromise()
-		.then(json => toastr.success(json.err))
-		.catch(err => toastr.error(err.url, `${err.status} (${err.statusText})`));
+		.then(response => toastr.success(response.err))
+		.catch(showRequestError);
 }
 }
 
 
-function LoadConfig() {
-	OMGUtils.AjaxReq(`${BASEURL}/admin/config`, {
+/**
+ * Loads the current configuration into the editor.
+ *
+ * @returns {void}
+ * @sideEffects Performs a network request and updates the configuration editor.
+ */
+function loadConfig() {
+	OMGUtils.AjaxReq(CONFIG_ENDPOINT, {
 		method: 'POST',
 		method: 'POST',
-		body: JSON.stringify({
-			"method": "loadconfig"
-		})
+		body: JSON.stringify({ method: LOAD_CONFIGURATION_METHOD }),
 	}).fetchPromise()
 	}).fetchPromise()
-		.then(json => {
-			$('#config_text').val(json.value);
-			toastr.success(json.err);
+		.then(response => {
+			$('#config_text').val(response.value);
+			toastr.success(response.err);
 		})
 		})
-		.catch(err => toastr.error(err.url, `${err.status} (${err.statusText})`));
+		.catch(showRequestError);
 }
 }
 
 
-function OpenESLDetails(labelID) {
-	modalEslDetails.OpenESLDetails(labelID);
-}
-
-function ESLMultiOperations(method) {
-	let selectedLabels = $('#datatable').DataTable().rows({selected: true}).data().toArray();
-
-	selectedLabels.forEach(labelID => {
+/**
+ * Selects or deselects all rows in a DataTable.
+ *
+ * @param {string} selector DataTable selector.
+ * @param {boolean} shouldSelect Whether rows should be selected.
+ * @returns {void}
+ * @sideEffects Changes the DataTable selection.
+ */
+function toggleSelectAll(selector, shouldSelect) {
+	const rows = $(selector).DataTable().rows({ page: 'current' });
 
 
-		OMGUtils.AjaxReq(`${BASEURL}/admin/config`, {
-			method: 'POST',
-			body: JSON.stringify({
-				"method": method,
-				"labelid": labelID[1]
-			})
-		}).fetchPromise()
-			.then(json => {
-				if (json.err == 0)
-					toastr.success(json.value);
-				else
-					toastr.error(json.value);
-			})
-			.catch(err => toastr.error(err.url, `${err.status} (${err.statusText})`));
+	if (shouldSelect) {
+		rows.select();
+		return;
+	}
 
 
-	});
+	rows.deselect();
 }
 }
 
 
 /**
 /**
- * Open the template selector modal window
+ * Initializes the admin interface once the document is ready.
  *
  *
- * @param bool clearCache
+ * @returns {void}
+ * @sideEffects Initializes third-party UI widgets and exposes legacy window functions.
  */
  */
-function TemplateSelector(clearCache) {
-	modalTemplateSelector.Open(clearCache);
-}
-
-function ToggleSelectAll(className, selectAll) {
-	if (selectAll)
-		$(className).DataTable().rows({page: 'current'}).select();
-	else
-		$(className).DataTable().rows().deselect();
-}
-
-// MAIN JS
-document.addEventListener('DOMContentLoaded', function () {
-
-	modalEslDetails = new ModalEslDetails('.statusmodal');
-	modalTemplateSelector = new ModalTemplateSelector('.templateselector');
-
+function initializeAdmin() {
 	Waves.attach('.btn', ['waves-effect', 'waves-light']);
 	Waves.attach('.btn', ['waves-effect', 'waves-light']);
 	Waves.init();
 	Waves.init();
 
 
-	$.fn.dataTable.ext.order['dom-checkbox'] = function (settings, col) {
-		return this.api().column(col, {order: 'index'}).nodes().map(function (td, i) {
-			return $(td).closest('tr').hasClass('selected') ? '1' : '0';
+	if (!$.fn.dataTable || !$('#datatable').length) {
+		return;
+	}
+
+	$.fn.dataTable.ext.order['dom-checkbox'] = function (settings, column) {
+		return this.api().column(column, { order: 'index' }).nodes().map(function (cell) {
+			return $(cell).closest('tr').hasClass('selected') ? '1' : '0';
 		});
 		});
 	};
 	};
 
 
-	// DataTable fix
-	$("#datatable").DataTable({
+	$('#datatable').DataTable({
 		paging: false,
 		paging: false,
 		scrollY: true,
 		scrollY: true,
 		responsive: true,
 		responsive: true,
-		fnInitComplete: function () {
-			$(".dataTables_scrollBody").slimscroll({
-				color: "#DDDDDD",
+		initComplete: function () {
+			$('.dt-scroll-body').slimscroll({
+				color: '#DDDDDD',
 				railVisible: true,
 				railVisible: true,
-				railColor: "black",
-				railOpacity: .3,
-				height: "50vh"
-			})
+				railColor: 'black',
+				railOpacity: 0.3,
+				height: '50vh',
+			});
 		},
 		},
 		columnDefs: [
 		columnDefs: [
-			{
-				targets: 0,
-				className: 'select-checkbox',
-				orderDataType: "dom-checkbox"
-			},
-			{
-				targets: -1,
-				orderable: false
-			}
+			{ targets: 0, className: 'select-checkbox', orderDataType: 'dom-checkbox' },
+			{ targets: -1, orderable: false },
 		],
 		],
-		select: {
-			style: 'multi',
-			selector: 'td:first-child',
-		}
+		select: { style: 'multi', selector: 'td:first-child' },
 	});
 	});
 
 
-	window.OpenESLDetails = OpenESLDetails;
-	window.LoadConfig = LoadConfig;
-	window.SaveConfig = SaveConfig;
-	window.ESLMultiOperations = ESLMultiOperations;
-	window.TemplateSelector = TemplateSelector;
-	window.ToggleSelectAll = ToggleSelectAll;
+}
 
 
-});
+document.addEventListener('DOMContentLoaded', initializeAdmin);

+ 10 - 10
ui/admin/assets/js/index.js

@@ -1,24 +1,25 @@
+import '../../../shared/jquery.js';
+import './admin.js';
+
 // Core Libraries
 // Core Libraries
-import 'popper.js';
 import 'bootstrap';
 import 'bootstrap';
 import 'bootstrap/dist/css/bootstrap.min.css';
 import 'bootstrap/dist/css/bootstrap.min.css';
-import 'node-waves';
 import 'jquery-slimscroll';
 import 'jquery-slimscroll';
 
 
 // Required datatable js
 // Required datatable js
 import 'datatables.net';
 import 'datatables.net';
-import 'datatables.net-bs4/css/dataTables.bootstrap4.min.css';
-import 'datatables.net-bs4/js/dataTables.bootstrap4.min.js';
+import 'datatables.net-bs5/css/dataTables.bootstrap5.min.css';
+import 'datatables.net-bs5';
 
 
 // Responsive examples
 // Responsive examples
 import 'datatables.net-responsive';
 import 'datatables.net-responsive';
-import 'datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css';
-import 'datatables.net-responsive-bs4/js/responsive.bootstrap4.min.js';
+import 'datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css';
+import 'datatables.net-responsive-bs5';
 
 
 // Selection table
 // Selection table
 import "datatables.net-select";
 import "datatables.net-select";
-import 'datatables.net-select-bs4/css/select.bootstrap4.min.css';
-import 'datatables.net-select-bs4/js/select.bootstrap4.min.js';
+import 'datatables.net-select-bs5/css/select.bootstrap5.min.css';
+import 'datatables.net-select-bs5';
 
 
 // Toastr
 // Toastr
 import 'toastr/build/toastr.min.css';
 import 'toastr/build/toastr.min.css';
@@ -35,7 +36,6 @@ import '../css/bootstrap-tagsinput.css';
 // App js
 // App js
 import './jquery.core.js';
 import './jquery.core.js';
 import './jquery.app.js';
 import './jquery.app.js';
-import "./admin.js";
 
 
-//TODO: remove if not need
+// TODO: Remove if not needed.
 // window.$ = window.jQuery = jQuery;
 // window.$ = window.jQuery = jQuery;

+ 8 - 18
ui/admin/masterpage_template.tpl

@@ -10,6 +10,8 @@
 	<script type="text/javascript">
 	<script type="text/javascript">
 		const BASEURL = '{{@BASEURL}}';
 		const BASEURL = '{{@BASEURL}}';
 	</script>
 	</script>
+
+	<!-- VITE_ASSETS -->
 </head>
 </head>
 <body>
 <body>
 
 
@@ -35,12 +37,12 @@
 
 
 			<div class="menu-extras topbar-custom">
 			<div class="menu-extras topbar-custom">
 
 
-				<ul class="list-unstyled topbar-right-menu float-right mb-0">
+				<ul class="list-unstyled topbar-right-menu float-end mb-0">
 					<li id="spinnerloader" class="d-none">
 					<li id="spinnerloader" class="d-none">
 						<a class="nav-link" href="#">
 						<a class="nav-link" href="#">
 							<div class="d-flex align-items-center">
 							<div class="d-flex align-items-center">
 								<strong>Loading...</strong>
 								<strong>Loading...</strong>
-								<div class="spinner-border text-warning ml-auto" role="status" aria-hidden="true"></div>
+								<div class="spinner-border text-warning ms-auto" role="status" aria-hidden="true"></div>
 							</div>
 							</div>
 						</a>
 						</a>
 					</li>
 					</li>
@@ -75,19 +77,19 @@
 					</li>
 					</li>
 
 
 					<li class="dropdown notification-list">
 					<li class="dropdown notification-list">
-						<a class="nav-link dropdown-toggle waves-effect waves-light nav-user" data-toggle="dropdown" href="#" role="button"
+						<a class="nav-link dropdown-toggle waves-effect waves-light nav-user" data-bs-toggle="dropdown" href="#" role="button"
 							 aria-haspopup="false" aria-expanded="false">
 							 aria-haspopup="false" aria-expanded="false">
 							<img src="{{@BASEURL}}/assets/images/users/default.png" alt="user" class="rounded-circle">
 							<img src="{{@BASEURL}}/assets/images/users/default.png" alt="user" class="rounded-circle">
 						</a>
 						</a>
-						<div class="dropdown-menu dropdown-menu-right profile-dropdown ">
+						<div class="dropdown-menu dropdown-menu-end profile-dropdown ">
 
 
 							<a href="javascript:void(0);" class="dropdown-item notify-item">
 							<a href="javascript:void(0);" class="dropdown-item notify-item">
-								<i class="ti-user m-r-5"></i> {{@USER.username}}
+								<i class="ti-user me-1"></i> {{@USER.username}}
 							</a>
 							</a>
 
 
 							<!-- item-->
 							<!-- item-->
 							<a href="{{@BASEURL}}/logout" class="dropdown-item notify-item">
 							<a href="{{@BASEURL}}/logout" class="dropdown-item notify-item">
-								<i class="ti-power-off m-r-5"></i> Logout
+								<i class="ti-power-off me-1"></i> Logout
 							</a>
 							</a>
 
 
 						</div>
 						</div>
@@ -165,18 +167,6 @@
 		<h4 class="">Notifications</h4>
 		<h4 class="">Notifications</h4>
 		<div class="notification-list nicescroll">
 		<div class="notification-list nicescroll">
 			<ul class="list-group list-no-border user-list">
 			<ul class="list-group list-no-border user-list">
-				<li class="list-group-item">
-					<a href="#" class="user-list-item">
-						<div class="avatar">
-							<img src="{{@BASEURL}}/assets/images/users/default.png" alt="">
-						</div>
-						<div class="user-desc">
-							<span class="name">FindBox service</span>
-							<span class="desc">Service unavailable</span>
-							<span class="time">1 seconds ago</span>
-						</div>
-					</a>
-				</li>
 			</ul>
 			</ul>
 		</div>
 		</div>
 	</div>
 	</div>

+ 4 - 4
ui/admin/omlsettings.tpl

@@ -1,16 +1,16 @@
 <div class="col-xl-4">
 <div class="col-xl-4">
 	<div class="card-box slimscroll" style="max-height: 50%;">
 	<div class="card-box slimscroll" style="max-height: 50%;">
-		<div class="dropdown pull-right">
-			<a href="#" class="dropdown-toggle arrow-none card-drop" data-toggle="dropdown" aria-expanded="false">
+		<div class="dropdown float-end">
+			<a href="#" class="dropdown-toggle arrow-none card-drop" data-bs-toggle="dropdown" aria-expanded="false">
 				<i class="mdi mdi-dots-vertical"></i>
 				<i class="mdi mdi-dots-vertical"></i>
 			</a>
 			</a>
-			<div class="dropdown-menu dropdown-menu-right">
+			<div class="dropdown-menu dropdown-menu-end">
 				<a href="javascript:SaveConfig();" class="dropdown-item">Save</a>
 				<a href="javascript:SaveConfig();" class="dropdown-item">Save</a>
 				<a href="javascript:LoadConfig();" class="dropdown-item">Reload</a>
 				<a href="javascript:LoadConfig();" class="dropdown-item">Reload</a>
 			</div>
 			</div>
 		</div>
 		</div>
 
 
-		<h4 class="header-title mt-0 m-b-30">config.ini</h4>
+		<h4 class="header-title mt-0 mb-4">config.ini</h4>
 		<div class="row">
 		<div class="row">
 			<div class="col-md-12">
 			<div class="col-md-12">
 				<textarea id="config_text" class="form-control" style="height: 100vh">{{@CONFIG_TEXT}}</textarea>
 				<textarea id="config_text" class="form-control" style="height: 100vh">{{@CONFIG_TEXT}}</textarea>

+ 16 - 25
ui/admin/package.json

@@ -7,39 +7,30 @@
 	"author": "Giancarlo Voci",
 	"author": "Giancarlo Voci",
 	"scripts": {
 	"scripts": {
 		"clean": "shx --silent rm -rf node_modules masterpage.tpl",
 		"clean": "shx --silent rm -rf node_modules masterpage.tpl",
-		"buildDev": "webpack --mode=none --display-error-details",
-		"buildProd": "webpack --mode=production --display-error-details",
-		"watch": "webpack --mode=none --display-error-details --watch"
+		"buildDev": "vite build --mode development",
+		"buildProd": "vite build --mode production",
+		"watch": "vite build --watch --mode development"
 	},
 	},
 	"dependencies": {
 	"dependencies": {
-		"bootstrap": "4.2.1",
-		"datatables.net": "^1.10.19",
-		"datatables.net-bs4": "^1.10.19",
-		"datatables.net-responsive": "^2.2.3",
-		"datatables.net-responsive-bs4": "^2.2.3",
-		"datatables.net-select": "^1.3.0",
-		"datatables.net-select-bs4": "^1.3.0",
-		"jquery": "^3.4.1",
+		"@popperjs/core": "^2.11.8",
+		"bootstrap": "^5.3.8",
+		"datatables.net": "^3.0.2",
+		"datatables.net-bs5": "^3.0.2",
+		"datatables.net-buttons": "^4.0.2",
+		"datatables.net-buttons-bs5": "^4.0.2",
+		"datatables.net-responsive": "^4.0.2",
+		"datatables.net-responsive-bs5": "^4.0.2",
+		"datatables.net-select": "^4.0.1",
+		"datatables.net-select-bs5": "^4.0.1",
+		"jquery": "^4.0.0",
 		"jquery-slimscroll": "^1.3.8",
 		"jquery-slimscroll": "^1.3.8",
 		"jszip": "^3.2.1",
 		"jszip": "^3.2.1",
 		"node-waves": "^0.7.6",
 		"node-waves": "^0.7.6",
 		"pdfmake": "^0.1.56",
 		"pdfmake": "^0.1.56",
-		"popper.js": "^1.15.0",
 		"toastr": "^2.1.4"
 		"toastr": "^2.1.4"
 	},
 	},
 	"devDependencies": {
 	"devDependencies": {
-		"clean-webpack-plugin": "^3.0.0",
-		"css-loader": "^2.1.1",
-		"file-loader": "^3.0.1",
-		"html-webpack-plugin": "^3.2.0",
-		"html-webpack-prefix-plugin": "^1.1.0",
-		"mini-css-extract-plugin": "^0.7.0",
-		"node-sass": "^4.12.0",
-		"optimize-css-assets-webpack-plugin": "^5.0.1",
-		"sass-loader": "^7.1.0",
-		"terser-webpack-plugin": "^1.3.0",
-		"url-loader": "^1.1.2",
-		"webpack": "^4.32.2",
-		"webpack-cli": "^3.3.2"
+		"sass": "^1.93.2",
+		"vite": "^7.1.3"
 	}
 	}
 }
 }

+ 5 - 7
ui/admin/users.tpl

@@ -3,30 +3,28 @@
 		<div class="card">
 		<div class="card">
 			<div class="card-header">
 			<div class="card-header">
 				<h5 class="m-0">
 				<h5 class="m-0">
-					<a class="text-dark" data-toggle="collapse" data-target="#{{@USER.username}}" aria-controls="collapseOne">
+					<a class="text-dark" data-bs-toggle="collapse" data-bs-target="#{{@USER.username}}" aria-controls="{{@USER.username}}">
 						{{@USER.username}}
 						{{@USER.username}}
 					</a>
 					</a>
 				</h5>
 				</h5>
 			</div>
 			</div>
 
 
-			<div id="{{@USER.username}}" class="collapse" data-target="#userlist">
+			<div id="{{@USER.username}}" class="collapse" data-bs-parent="#userlist">
 				<div class="card-body">
 				<div class="card-body">
 					<form class="form">
 					<form class="form">
-						<div class="form-group">
+						<div class="mb-3">
 							<input type="text" class="mb-1 form-control" placeholder="username" value="{{@USER.username}}"/>
 							<input type="text" class="mb-1 form-control" placeholder="username" value="{{@USER.username}}"/>
 
 
 							<div class="input-group mb-1">
 							<div class="input-group mb-1">
 								<input type="text" class="form-control" placeholder="password" aria-label="password" value="">
 								<input type="text" class="form-control" placeholder="password" aria-label="password" value="">
-								<div class="input-group-append">
-									<button class="btn btn-primary" type="button"><i class="fa fa-eye"></i></button>
-								</div>
+								<button class="btn btn-primary" type="button"><i class="fa fa-eye"></i></button>
 							</div>
 							</div>
 
 
 
 
 							<input type="text" class="form-control" placeholder="roles" data-role="tagsinput" value="{{join(',',@USER->getRoles())}}"/>
 							<input type="text" class="form-control" placeholder="roles" data-role="tagsinput" value="{{join(',',@USER->getRoles())}}"/>
 						</div>
 						</div>
 
 
-						<div class="form-group">
+						<div class="mb-3">
 							<button class="btn btn-primary" type="button">Save</button>
 							<button class="btn btn-primary" type="button">Save</button>
 							<button class="btn btn-primary" type="reset">Reset</button>
 							<button class="btn btn-primary" type="reset">Reset</button>
 						</div>
 						</div>

+ 32 - 0
ui/admin/vite.config.js

@@ -0,0 +1,32 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+import viteTemplatePlugin from '../../scripts/vite-template-plugin.mjs';
+
+const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+const ADMIN_ENTRY = path.resolve(PROJECT_ROOT, 'ui/admin/assets/js/index.js');
+
+export default defineConfig({
+	root: PROJECT_ROOT,
+	base: '/phengine/assets/admin/',
+	plugins: [
+		viteTemplatePlugin({
+			entry: ADMIN_ENTRY,
+			templates: [
+				{
+					source: 'ui/admin/masterpage_template.tpl',
+					output: 'ui/admin/masterpage.tpl',
+				},
+			],
+		}),
+	],
+	build: {
+		outDir: path.resolve(PROJECT_ROOT, 'assets/admin'),
+		emptyOutDir: true,
+		manifest: 'manifest.json',
+		sourcemap: false,
+		rollupOptions: {
+			input: ADMIN_ENTRY,
+		},
+	},
+});

+ 33 - 29
ui/admin/webpack.config.js

@@ -1,15 +1,25 @@
 const path = require("path");
 const path = require("path");
-const TerserJSPlugin = require('terser-webpack-plugin');
+const TerserJSPlugin = require("terser-webpack-plugin");
+const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
 const HtmlPlugin = require("html-webpack-plugin");
 const HtmlPlugin = require("html-webpack-plugin");
 const MiniCssExtractPlugin = require("mini-css-extract-plugin");
 const MiniCssExtractPlugin = require("mini-css-extract-plugin");
-const { CleanWebpackPlugin } = require("clean-webpack-plugin/dist/clean-webpack-plugin");
-const OptimizeCSSAssetsPlugin = require('css-minimizer-webpack-plugin');
+const { CleanWebpackPlugin } = require("clean-webpack-plugin");
 const webpack = require("webpack");
 const webpack = require("webpack");
 
 
-function getCurrentProjectname() {
-	let pathSplit = __dirname.split(path.sep);
+const ASSET_INLINE_LIMIT = 100000;
+const TERSER_PARALLEL_WORKERS = 4;
+const PROJECT_NAME_PATH_OFFSET = -3;
 
 
-	return pathSplit[pathSplit.length - 3];
+/**
+ * Gets the project name from the webpack configuration directory.
+ *
+ * @returns {string} The project directory name.
+ * @sideEffects None.
+ */
+function getCurrentProjectName() {
+	const pathParts = __dirname.split(path.sep);
+
+	return pathParts[pathParts.length + PROJECT_NAME_PATH_OFFSET];
 }
 }
 
 
 module.exports = {
 module.exports = {
@@ -18,12 +28,10 @@ module.exports = {
 	},
 	},
 	plugins: [
 	plugins: [
 		new CleanWebpackPlugin(),
 		new CleanWebpackPlugin(),
-		new HtmlPlugin(
-			{
-				template: "masterpage_template.tpl",
-				filename: path.resolve(__dirname, 'masterpage.tpl')
-			}
-		),
+		new HtmlPlugin({
+			template: path.resolve(__dirname, "masterpage_template.tpl"),
+			filename: path.resolve(__dirname, "masterpage.tpl")
+		}),
 		new MiniCssExtractPlugin({ filename: "styles.[contenthash].css" }),
 		new MiniCssExtractPlugin({ filename: "styles.[contenthash].css" }),
 		new webpack.ProvidePlugin({
 		new webpack.ProvidePlugin({
 			$: 'jquery',
 			$: 'jquery',
@@ -35,40 +43,36 @@ module.exports = {
 	module: {
 	module: {
 		rules: [
 		rules: [
 			{
 			{
-				// For css...
 				test: /\.(css|scss)$/,
 				test: /\.(css|scss)$/,
-				// ...use this loaders
 				use: [
 				use: [
 					{ loader: MiniCssExtractPlugin.loader },
 					{ loader: MiniCssExtractPlugin.loader },
 					{ loader: "css-loader" },
 					{ loader: "css-loader" },
 					{ loader: "sass-loader" }
 					{ loader: "sass-loader" }
 				]
 				]
 			},
 			},
+
 			// Import various assets
 			// Import various assets
 			{
 			{
-				test: /\.(jpg|png|woff|woff2|eot|ttf|svg)$/,
-				use: [
-					{ loader: 'url-loader', options: '?limit=100000' }
-				]
+				test: /\.(jpg|png|woff|woff2|eot|ttf|svg)$/i,
+				type: "asset",
+				parser: {
+					dataUrlCondition: { maxSize: ASSET_INLINE_LIMIT }
+				}
 			}
 			}
 		]
 		]
 	},
 	},
 	optimization: {
 	optimization: {
 		minimizer: [
 		minimizer: [
-			new TerserJSPlugin(
-				// Discard Comments
-				{ terserOptions: { output: { comments: false } }, parallel: 4 }
-			),
-			new OptimizeCSSAssetsPlugin(
-				// Discard Comments
-				{ cssProcessorPluginOptions: { preset: ['default', { discardComments: { removeAll: true } }] } }
-			)
+			new TerserJSPlugin({
+				terserOptions: { format: { comments: false } },
+				parallel: TERSER_PARALLEL_WORKERS
+			}),
+			new CssMinimizerPlugin()
 		],
 		],
 	},
 	},
 	entry: "./assets/js/index.js",
 	entry: "./assets/js/index.js",
-	output:
-	{
-		publicPath: '/' + getCurrentProjectname() + '/assets/admin/',
+	output: {
+		publicPath: '/' + getCurrentProjectName() + '/assets/admin/',
 		path: path.resolve(__dirname, "../../assets/admin/"),
 		path: path.resolve(__dirname, "../../assets/admin/"),
 		filename: "bundle.[contenthash].js"
 		filename: "bundle.[contenthash].js"
 	}
 	}

+ 2 - 2
ui/frontend/bind.tpl

@@ -1,5 +1,5 @@
 <div class="row h-100 justify-content-center w-100">
 <div class="row h-100 justify-content-center w-100">
-	<div class="col-xs-12 col-md-9 col-lg-5 align-self-center">
+	<div class="col-12 col-md-9 col-lg-5 align-self-center">
 
 
 		<check if="{{ isset(@INFOTEXT) }}">
 		<check if="{{ isset(@INFOTEXT) }}">
 			<div class="alert alert-danger" role="alert">
 			<div class="alert alert-danger" role="alert">
@@ -26,7 +26,7 @@
 						<repeat group="{{ @LISTE }}" key="{{@key}}" value="{{ @LISTA }}">
 						<repeat group="{{ @LISTE }}" key="{{@key}}" value="{{ @LISTA }}">
 							<tr>
 							<tr>
 								<td {{(@key==0)?
 								<td {{(@key==0)?
-								'class="blink bg-success text-black font-weight-bold"': ''}}>{{@LISTA.commessa}}</td>
+								'class="blink bg-success text-black fw-bold"': ''}}>{{@LISTA.commessa}}</td>
 							</tr>
 							</tr>
 						</repeat>
 						</repeat>
 						</tbody>
 						</tbody>

+ 8 - 6
ui/frontend/css/edittable.scss

@@ -13,17 +13,19 @@ table.dataTable {
 }
 }
 
 
 table.dataTable thead {
 table.dataTable thead {
-	.sorting::after,
-	.sorting_asc::after,
-	.sorting_desc::after {
+	.dt-orderable-asc .dt-column-order::after,
+	.dt-orderable-desc .dt-column-order::after,
+	.dt-ordering-asc .dt-column-order::after,
+	.dt-ordering-desc .dt-column-order::after {
 		content: "\25B2";
 		content: "\25B2";
 		font-size: $dt-fontSize;
 		font-size: $dt-fontSize;
 		top: $dt-topArrows;
 		top: $dt-topArrows;
 	}
 	}
 
 
-	.sorting::before,
-	.sorting_asc::before,
-	.sorting_desc::before {
+	.dt-orderable-asc .dt-column-order::before,
+	.dt-orderable-desc .dt-column-order::before,
+	.dt-ordering-asc .dt-column-order::before,
+	.dt-ordering-desc .dt-column-order::before {
 		content: "\25BC";
 		content: "\25BC";
 		font-size: $dt-fontSize;
 		font-size: $dt-fontSize;
 		top: $dt-topArrows;
 		top: $dt-topArrows;

+ 14 - 6
ui/frontend/errors_template.tpl

@@ -7,6 +7,8 @@
 	<script type="text/javascript">
 	<script type="text/javascript">
 		const BASEURL = '{{@BASEURL}}';
 		const BASEURL = '{{@BASEURL}}';
 	</script>
 	</script>
+
+	<!-- VITE_ASSETS -->
 </head>
 </head>
 <body class="bg-light error_body">
 <body class="bg-light error_body">
 
 
@@ -14,12 +16,18 @@
 	<div class="row">
 	<div class="row">
 		<div class="col-md-6 mx-auto">
 		<div class="col-md-6 mx-auto">
 			<div class="card bg-white m-1 p-2">
 			<div class="card bg-white m-1 p-2">
-				<h1 class="text-center text-info">{{@ERROR.code}} {{@ERROR.status}}</h1>
-				<br/>
-				<h3 class="text-success">Dettaglio errore:</h3>
-				<p style="color: black">{{@ERROR.text | raw}}</p>
-				<h3 class="text-success">Debug Trace:</h3>
-				<pre style="font-size: 0.8rem">{{@ERROR.trace | raw}}</pre>
+				<check if="{{ @DEV }}">
+					<h1 class="text-center text-info">{{@ERROR.code}} {{@ERROR.status}}</h1>
+					<br/>
+					<h3 class="text-success">Dettaglio errore:</h3>
+					<p style="color: black">{{@ERROR.text}}</p>
+					<h3 class="text-success">Debug Trace:</h3>
+					<pre style="font-size: 0.8rem">{{@ERROR.trace}}</pre>
+				</check>
+				<check if="{{ !@DEV }}">
+					<h1 class="text-center text-info">Si è verificato un errore</h1>
+					<p style="color: black">Riprova più tardi.</p>
+				</check>
 				<br/>
 				<br/>
 				<a class="text-uppercase" href="{{@BASEURL}}">Torna alla pagina principale.</a>
 				<a class="text-uppercase" href="{{@BASEURL}}">Torna alla pagina principale.</a>
 			</div>
 			</div>

+ 4 - 2
ui/frontend/footer.tpl

@@ -2,9 +2,11 @@
 	<check if="{{ isset(@PREVPAGE) }}">
 	<check if="{{ isset(@PREVPAGE) }}">
 		<a href="{{@PREVPAGE}}"><img class="navbar-brand" height="50px" src="{{@BASEURL}}/assets/images/arrow-left.svg"/></a>
 		<a href="{{@PREVPAGE}}"><img class="navbar-brand" height="50px" src="{{@BASEURL}}/assets/images/arrow-left.svg"/></a>
 	</check>
 	</check>
-	<div class="nav-item">Utente: {{@COOKIE.username}}</div>
+	<check if="{{ isset(@COOKIE.username) }}">
+		<div class="nav-item">Utente: {{@COOKIE.username}}</div>
+	</check>
 	<div class="collapse navbar-collapse"></div>
 	<div class="collapse navbar-collapse"></div>
-	<div class="nav-item text-dark pr-2" style="font-size: 0.7em">Version: {{@VERSION}}</div>
+	<div class="nav-item text-dark pe-2" style="font-size: 0.7em">Version: {{@VERSION}}</div>
 	<div class="navbar-brand">
 	<div class="navbar-brand">
 		<a href="{{@BASEURL}}">
 		<a href="{{@BASEURL}}">
 			<img height="40" src="{{@BASEURL}}/assets/images/om-link-logo.png"/>
 			<img height="40" src="{{@BASEURL}}/assets/images/om-link-logo.png"/>

+ 5 - 5
ui/frontend/header.tpl

@@ -1,6 +1,6 @@
 <nav class="navbar navbar-expand-sm navbar-dark bg-dark om-shadow">
 <nav class="navbar navbar-expand-sm navbar-dark bg-dark om-shadow">
 	<a href="{{@BASEURL}}/"><img class="img-fluid om-shadow navbar-brand customerlogo" src="{{@LOGO_CUSTOMER}}"/></a>
 	<a href="{{@BASEURL}}/"><img class="img-fluid om-shadow navbar-brand customerlogo" src="{{@LOGO_CUSTOMER}}"/></a>
-	<button aria-controls="headerMenu" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler" data-target="#headerMenu" data-toggle="collapse" type="button">
+	<button aria-controls="headerMenu" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler" data-bs-target="#headerMenu" data-bs-toggle="collapse" type="button">
 		<span class="navbar-toggler-icon"></span>
 		<span class="navbar-toggler-icon"></span>
 	</button>
 	</button>
 
 
@@ -16,12 +16,12 @@
 		</ul>
 		</ul>
 	</div>
 	</div>
 
 
-	<div class="ml-auto dropdown dropleft navbar-brand">
-		<a aria-expanded="false" aria-haspopup="true" class="disabled" class="mr-3" data-toggle="dropdown" href="#" id="notification_area_button" style="position: relative">
+	<div class="ms-auto dropdown dropstart navbar-brand">
+		<a aria-expanded="false" aria-haspopup="true" class="disabled me-3" data-bs-toggle="dropdown" href="#" id="notification_area_button" style="position: relative">
 			<img src="{{@BASEURL}}/assets/images/bell.png"/>
 			<img src="{{@BASEURL}}/assets/images/bell.png"/>
-			<span class="blink badge badge-light" id="sysmsgnum"></span>
+			<span class="blink badge bg-light text-dark" id="sysmsgnum"></span>
 		</a>
 		</a>
-		<div aria-labelledby="dropdownMenuButton" class="dropdown-menu" id="notification_area"></div>
+		<div aria-labelledby="notification_area_button" class="dropdown-menu" id="notification_area"></div>
 	</div>
 	</div>
 	<div>
 	<div>
 		<a href="{{@BASEURL}}/logout"><img class="img-fluid om-shadow" src="{{@ICON_LOGOUT}}"/></a>
 		<a href="{{@BASEURL}}/logout"><img class="img-fluid om-shadow" src="{{@ICON_LOGOUT}}"/></a>

+ 1 - 53
ui/frontend/js/OMGUtils.js

@@ -1,53 +1 @@
-export const fetchHeaders = {
-	'Accept': 'text/html',
-	'Content-Type': 'text/html',
-	'X-Requested-With': 'XMLHttpRequest',
-	'Cache-Control': 'no-cache',
-	'Pragma': 'no-cache',
-	'Expires': '0'
-};
-
-/**
- * Make an AJAX request.
- *
- * If error, catch return a promise error object:
- *
- *  .catch(err => alert(`${err.url}\n\n${err.status} (${err.statusText})`));
- *
- * @param url URL of RESTful api to call
- * @param options the fetch() options
- * @returns Object {Promise, signal: AbortController}
- */
-export function AjaxReq(url, options) {
-	let fetchSignal = new AbortController();
-	let defaults = {
-		headers: fetchHeaders,
-		signal: fetchSignal.signal
-	};
-	// Extend default options
-	Object.assign(defaults, options);
-
-	let fetchPromise = fetch(url, defaults)
-		.then(resp => {
-			if (resp.status === 200)
-				return resp.json();
-			else
-				return Promise.reject(resp);
-		})
-		.catch(err => {
-			if (err.name === 'AbortError')
-				return;
-			if (err.url !== undefined)
-				toastr.error(err.url, `${err.status} (${err.statusText})`);
-			else
-				toastr.error(err);
-		});
-
-	// Add some properties to return value
-	return {
-		fetchPromise: () => fetchPromise,
-		abort: function () {
-			fetchSignal.abort()
-		}
-	};
-}
+export { AjaxReq, fetchHeaders } from '../../shared/OMGUtils';

+ 10 - 3
ui/frontend/js/common.js

@@ -1,9 +1,16 @@
-// Libs
-import 'bootstrap';
-import 'popper.js';
+import '../../shared/jquery.js';
+
+// Core libraries
+import 'bootstrap/dist/js/bootstrap.bundle.min.js';
+import 'datatables.net';
+import 'datatables.net-bs5';
+import 'datatables.net-buttons';
+import 'datatables.net-buttons-bs5';
 
 
 // CSS
 // CSS
 import 'bootstrap/dist/css/bootstrap.min.css';
 import 'bootstrap/dist/css/bootstrap.min.css';
+import 'datatables.net-bs5/css/dataTables.bootstrap5.min.css';
+import 'datatables.net-buttons-bs5/css/buttons.bootstrap5.min.css';
 import '../css/omlink.scss';
 import '../css/omlink.scss';
 import '../css/edittable.scss';
 import '../css/edittable.scss';
 
 

+ 140 - 110
ui/frontend/js/edittable.js

@@ -1,141 +1,171 @@
-import {AjaxReq, fetchHeaders} from "./OMGUtils";
-
-let CSS_EDITOR_ACTIVE = 'shadow rounded-lg bg-dark text-light';
-let CSS_EDITOR_ROW_ACTIVE = 'bg-info';
-
-let _tableSelector = null;
-let _controllerURL = '';
-
-function GetTableFieldNames() {
-	let columnNames = [];
-	$(`${_tableSelector} thead th`).each(function (i, e) {
-		columnNames.push(e.innerText);
-	});
-
-	return columnNames;
+import $ from '../../shared/jquery.js';
+import { AjaxReq, fetchHeaders } from './OMGUtils';
+
+const EDIT_ACTION = 'edit';
+const EDITABLE_COLUMN_ATTRIBUTE = 'aria-editable';
+const EDIT_ACTIVE_CLASS = 'shadow rounded bg-dark text-light';
+const EDIT_ACTIVE_ROW_CLASS = 'bg-info';
+const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded';
+const NON_EDITABLE_VALUE = 'false';
+
+let tableSelector = null;
+let controllerUrl = '';
+
+/**
+ * Returns the editable table column names.
+ *
+ * @returns {string[]} Column names.
+ * @sideEffects Reads the document.
+ */
+function getTableFieldNames() {
+	return $(`${tableSelector} thead th`).map(function () {
+		return this.innerText;
+	}).get();
 }
 }
 
 
-function GetColumnDef(colIndex, name) {
-	return $(`${_tableSelector} thead th:eq(${colIndex})`).attr(name);
+/**
+ * Returns an attribute from a table column definition.
+ *
+ * @param {number} columnIndex Column index.
+ * @param {string} attributeName Attribute name.
+ * @returns {string|undefined} Attribute value.
+ * @sideEffects Reads the document.
+ */
+function getColumnAttribute(columnIndex, attributeName) {
+	return $(`${tableSelector} thead th`).eq(columnIndex).attr(attributeName);
 }
 }
 
 
-function cancelEdit(el) {
-	el.innerText = $(el).data('oldvalue');
-	$(el).parent('tr').removeClass(`dt-editing-cell ${CSS_EDITOR_ROW_ACTIVE}`);
-	$(el).removeClass(CSS_EDITOR_ACTIVE);
+/**
+ * Cancels the edit of a table cell and restores its previous value.
+ *
+ * @param {HTMLElement} cell Table cell.
+ * @returns {void}
+ * @sideEffects Updates the edited table cell.
+ */
+function cancelEdit(cell) {
+	cell.innerText = $(cell).data('oldvalue');
+	$(cell).parent('tr').removeClass(`dt-editing-cell ${EDIT_ACTIVE_ROW_CLASS}`);
+	$(cell).removeClass(EDIT_ACTIVE_CLASS);
 	$('body').focus();
 	$('body').focus();
-	el.contentEditable = false;
+	cell.contentEditable = 'false';
 }
 }
 
 
-function EditTableInit(tableRef, controllerURL) {
+/**
+ * Shows a request error without discarding diagnostic context.
+ *
+ * @param {unknown} error Request failure.
+ * @returns {void}
+ * @sideEffects Logs the failure and displays an alert.
+ */
+function showRequestError(error) {
+	console.error('Editable table request failed.', error);
+	alert('Unable to save the table changes.');
+}
 
 
-	_tableSelector = tableRef;
-	_controllerURL = controllerURL;
+/**
+ * Runs a table command using form-urlencoded data.
+ *
+ * @param {string} action Server action.
+ * @returns {void}
+ * @sideEffects Performs a network request and displays an alert.
+ */
+function executeTableAction(action) {
+	AjaxReq(controllerUrl, {
+		headers: { ...fetchHeaders, 'Content-Type': FORM_CONTENT_TYPE },
+		method: 'POST',
+		body: `action=${action}`,
+	}).fetchPromise()
+		.then(response => alert(`${response.code} - ${response.err}`))
+		.catch(showRequestError);
+}
 
 
-	let tableBodyRef = $(`${_tableSelector} tbody`);
+/**
+ * Sends the currently edited row to the server.
+ *
+ * @param {HTMLElement} cell Edited cell.
+ * @returns {void}
+ * @sideEffects Performs a network request and refreshes the DataTable.
+ */
+function saveEdit(cell) {
+	const formData = new FormData();
+	const fields = getTableFieldNames();
+
+	$('.dt-editing-cell td').each(function (index) {
+		formData.append(fields[index], this.innerText);
+	});
+	formData.append('action', EDIT_ACTION);
+
+	AjaxReq(controllerUrl, { method: 'POST', body: formData }).fetchPromise()
+		.then(response => {
+			$(tableSelector).DataTable().ajax.reload();
+			alert(`${response.code} - ${response.err}`);
+		})
+		.catch(showRequestError)
+		.finally(() => {
+			cell.contentEditable = 'false';
+		});
+}
 
 
-	$(tableRef).DataTable({
-		"ajax": {
-			"url": _controllerURL,
-			"type": 'POST',
-			"data": {
-				'action': 'getData'
-			},
-			'dataSrc': function (data) {
-				$(_tableSelector).parent('.dataTables_wrapper').parent().removeClass('d-none');
+/**
+ * Initializes an editable DataTable.
+ *
+ * @param {string} tableReference DataTable selector.
+ * @param {string} controllerUrlReference Server endpoint.
+ * @returns {void}
+ * @sideEffects Initializes the table and registers DOM event handlers.
+ */
+function initializeEditableTable(tableReference, controllerUrlReference) {
+	tableSelector = tableReference;
+	controllerUrl = controllerUrlReference;
+	const tableBody = $(`${tableSelector} tbody`);
+
+	$(tableSelector).DataTable({
+		ajax: {
+			url: controllerUrl,
+			type: 'POST',
+			data: { action: 'getData' },
+			dataSrc(data) {
+				$(tableSelector).closest('.dt-container').parent().removeClass('d-none');
 				return data;
 				return data;
-			}
+			},
 		},
 		},
-		'lengthChange': false,
-		dom: 'frtBip', // for buttons
-		order: [[0, "asc"]],
+		lengthChange: false,
+		dom: 'frtBip',
+		order: [[0, 'asc']],
 		buttons: [
 		buttons: [
-			{
-				text: 'Completa lista',
-				action: function (e, dt, node, config) {
-					let postHeadersContentType = fetchHeaders;
-					postHeadersContentType['Content-Type'] = 'application/x-www-form-urlencoded';
-					AjaxReq(_controllerURL, {
-						headers: postHeadersContentType,
-						method: 'POST',
-						body: 'action=closelist'
-					}).fetchPromise()
-						.then(json => alert(`${json.code} - ${json.err}`))
-						.catch(err => alert(err.url, `${err.status} (${err.statusText})`));
-				}
-			},
-			{
-				text: 'Annulla lista',
-				action: function (e, dt, node, config) {
-					let postHeadersContentType = fetchHeaders;
-					postHeadersContentType['Content-Type'] = 'application/x-www-form-urlencoded';
-					AjaxReq(_controllerURL, {
-						headers: postHeadersContentType,
-						method: 'POST',
-						body: 'action=cancellist'
-					}).fetchPromise()
-						.then(json => alert(`${json.code} - ${json.err}`))
-						.catch(err => alert(err.url, `${err.status} (${err.statusText})`));
-				}
-			}
-		]
+			{ text: 'Completa lista', action: () => executeTableAction('closelist') },
+			{ text: 'Annulla lista', action: () => executeTableAction('cancellist') },
+		],
 	});
 	});
 
 
-	tableBodyRef.on('click', 'td', function () {
-		let $this = $(this);
-
-		// avoid to edit if aria-editable === 'false'
-		if (GetColumnDef(this.cellIndex, 'aria-editable') === 'false')
+	tableBody.on('click', 'td', function () {
+		if (getColumnAttribute(this.cellIndex, EDITABLE_COLUMN_ATTRIBUTE) === NON_EDITABLE_VALUE) {
 			return;
 			return;
+		}
 
 
-		this.contentEditable = true;	// enable html edit
-		$this.data('oldvalue', this.innerText);  // backup actual value
-		$this.parent('tr').addClass(`dt-editing-cell ${CSS_EDITOR_ROW_ACTIVE}`);  // mark the row of current cell
-		$this.addClass(CSS_EDITOR_ACTIVE);
-		$this.focus();
+		this.contentEditable = 'true';
+		$(this).data('oldvalue', this.innerText);
+		$(this).parent('tr').addClass(`dt-editing-cell ${EDIT_ACTIVE_ROW_CLASS}`);
+		$(this).addClass(EDIT_ACTIVE_CLASS);
+		this.focus();
 	});
 	});
 
 
-	tableBodyRef.on('blur', 'td', function (e) {
+	tableBody.on('blur', 'td', function () {
 		cancelEdit(this);
 		cancelEdit(this);
 	});
 	});
 
 
-	tableBodyRef.on('keydown', 'td', function (e) {
-		// Cancel Edit
-		if (e.code === 27 || e.code === 9) {
+	tableBody.on('keydown', 'td', function (event) {
+		if (event.code === 'Escape' || event.code === 'Tab') {
 			cancelEdit(this);
 			cancelEdit(this);
 			return false;
 			return false;
 		}
 		}
-		// Save Modified Data
-		if (e.code === 13) {
-			$(this).data('oldvalue', this.innerText);
-
-			let ajaxData = new FormData();
-			let fields = GetTableFieldNames();
-
-			$('.dt-editing-cell td').each(function (i, e) {
-				ajaxData.append(fields[i], e.innerText);
-			});
-
-			ajaxData.append('action', 'edit');
-
-			let postHeadersContentType = fetchHeaders;
-			delete postHeadersContentType['Content-Type'];
-
-			AjaxReq(_controllerURL, {
-				headers: postHeadersContentType,
-				method: 'POST',
-				body: ajaxData
-			}).fetchPromise()
-				.then(json => {
-					$(tableRef).DataTable().ajax.reload();
-					alert(`${json.code} - ${json.err}`);
-				})
-				.catch(err => alert(err.url, `${err.status} (${err.statusText})`))
-				.finally(() => this.contentEditable = false);
 
 
+		if (event.code === 'Enter') {
+			$(this).data('oldvalue', this.innerText);
+			saveEdit(this);
 			return false;
 			return false;
 		}
 		}
 	});
 	});
 }
 }
 
 
-export default EditTableInit;
+export default initializeEditableTable;

+ 104 - 74
ui/frontend/js/frontend.js

@@ -6,114 +6,144 @@
  * @time 17:42
  * @time 17:42
  */
  */
 
 
+import $ from '../../shared/jquery.js';
 import './common';
 import './common';
-import { AjaxReq, fetchHeaders } from "./OMGUtils";
-//import EditTableInit from "./edittable";
+import { AjaxReq } from './OMGUtils';
 
 
-let CUSTOM_NOTIFY = 0x4000;
-let REFRESH_FILTERS = CUSTOM_NOTIFY + 1;
+const CUSTOM_NOTIFICATION_CODE = 0x4000;
+const FOCUS_INTERVAL_MS = 1000;
+const NOTIFICATION_REFRESH_INTERVAL_MS = 3000;
+const DELETE_ALL_NOTIFICATION_LINK = '#';
 
 
-/* usefull if a blinking text is need, like in MaxMara project, you need to place this code in html:
+/**
+ * Adds a notification item to the notification menu.
  *
  *
- * <nav class="navbar navbar-collapse p-0">
- *   <span class="navbar-text blink" style="color: red" id="statusbartext"></span>
- * </nav>
+ * @param {{caption: string, code: number, count: number, link: string|null, timestamp: string}} notification Notification data.
+ * @returns {JQuery} Notification menu item.
+ * @sideEffects Registers a click handler.
  */
  */
-function SetStatusBarText(msg) {
-	$('#statusbartext').text(msg);
-	setTimeout(() => {
-		$('#statusbartext').text('');
-	}, 60000);
+function createNotificationItem(notification) {
+	return $('<a>', { class: 'dropdown-item', href: '#' })
+		.text(`[${notification.timestamp}] ${notification.caption}`)
+		.append($('<span>', { class: 'ms-1 badge bg-danger', text: notification.count }))
+		.on('click', event => {
+			event.preventDefault();
+			deleteNotification(notification.link, notification.code);
+		});
 }
 }
 
 
-function NotificationBuilder(notifications) {
-	let tplNotify = '';
-	let htmlNotificationArea = '';
-	if (notifications.length === 0) {
+/**
+ * Renders the notifications visible to the user.
+ *
+ * @param {Array<object>} notifications Notifications returned by the API.
+ * @returns {void}
+ * @sideEffects Updates the notification menu.
+ */
+function renderNotifications(notifications) {
+	const visibleNotifications = notifications.filter(
+		notification => notification.code <= CUSTOM_NOTIFICATION_CODE,
+	);
+	const notificationArea = $('#notification_area').empty();
+
+	if (visibleNotifications.length === 0) {
 		$('#notification_area_button').addClass('disabled');
 		$('#notification_area_button').addClass('disabled');
 		$('#sysmsgnum').empty();
 		$('#sysmsgnum').empty();
-	} else {
-		let notificationCount = 0;
-		notifications.forEach(function (element) {
-			// If notification codes are >CUSTON_NOTIFY means that are special codes to skip in this logic
-			if (element.code > CUSTOM_NOTIFY)
-				return;
-
-			tplNotify += `<a class="dropdown-item" href="javascript:NotificationDelete('${element.link}', ${element.code})">[${element.timestamp}] ${element.caption}<span class="ml-1 badge badge-danger">${element.count}</span></a>`;
-			notificationCount++;
-		});
-
-		htmlNotificationArea = `
-${tplNotify}
-<a id="notification_area_deleteall" class="dropdown-item text-center" href="javascript:NotificationDelete('#', 0)" >Cancella tutto</a>
-`;
-		$('#notification_area_button').removeClass('disabled');
-		$('#sysmsgnum').html(notificationCount);
+		return;
 	}
 	}
 
 
-	$('#notification_area').html(htmlNotificationArea);
+	visibleNotifications.forEach(notification => notificationArea.append(createNotificationItem(notification)));
+	notificationArea.append(
+		$('<a>', { id: 'notification_area_deleteall', class: 'dropdown-item text-center', href: '#', text: 'Cancella tutto' })
+			.on('click', event => {
+				event.preventDefault();
+				deleteNotification(DELETE_ALL_NOTIFICATION_LINK, 0);
+			}),
+	);
+	$('#notification_area_button').removeClass('disabled');
+	$('#sysmsgnum').text(visibleNotifications.length);
 }
 }
 
 
+/**
+ * Retrieves and renders the current system notifications.
+ *
+ * @returns {void}
+ * @sideEffects Performs a network request and updates the notification menu.
+ */
 function refreshNotificationArea() {
 function refreshNotificationArea() {
-	fetch(`${BASEURL}/api.system.getserverstatus`, { headers: fetchHeaders })
-		.then(resp => {
-			if (resp.status === 200)
-				return resp.json();
-			else
-				return Promise.reject("getserverstatus: error");
-		})
-		.then(data => {
-			NotificationBuilder(data.notify);
-		});
+	AjaxReq(`${BASEURL}/api.system.getserverstatus`, { method: 'GET' }).fetchPromise()
+		.then(data => renderNotifications(data.notify))
+		.catch(error => console.error('Unable to refresh system notifications.', error));
 }
 }
 
 
-function NotificationDelete(link, code) {
-	fetch(`${BASEURL}/api.system.removenotification/${code}`, { method: "POST" })
-		.then(() => {
-			if ((link != null) && (link.length != 0) && (link != '#'))
+/**
+ * Deletes one notification and optionally follows its link.
+ *
+ * @param {string|null} link Link associated with the notification.
+ * @param {number} code Notification code.
+ * @returns {void}
+ * @sideEffects Performs a network request and may navigate the browser.
+ */
+function deleteNotification(link, code) {
+	fetch(`${BASEURL}/api.system.removenotification/${code}`, { method: 'POST' })
+		.then(response => {
+			if (!response.ok) {
+				return Promise.reject(response);
+			}
+
+			if (link && link !== DELETE_ALL_NOTIFICATION_LINK) {
 				window.location.href = `${BASEURL}${link}`;
 				window.location.href = `${BASEURL}${link}`;
+			}
 		})
 		})
-		.catch(() => {
+		.catch(error => {
+			console.error('Unable to delete the notification.', { code, error });
 			alert('NotificationDelete error');
 			alert('NotificationDelete error');
 		});
 		});
 }
 }
 
 
-function SetupConfirmationDialog() {
-	$('.requestconfirm').on('click', function (ev) {
-		let confirmText = $(this).data('confirmtext');
-		if (!confirm(confirmText))
-			ev.preventDefault();
+/**
+ * Enables confirmation prompts for elements that require explicit user approval.
+ *
+ * @returns {void}
+ * @sideEffects Registers click handlers.
+ */
+function setupConfirmationDialog() {
+	$('.requestconfirm').on('click', function (event) {
+		if (!confirm($(this).data('confirmtext'))) {
+			event.preventDefault();
+		}
 	});
 	});
 }
 }
 
 
-function ForceFocus() {
-	setInterval(function () {
-		$("[forcefocus]").focus();
-	}, 1000);
+/**
+ * Keeps elements marked with forcefocus focused.
+ *
+ * @returns {void}
+ * @sideEffects Starts an interval timer.
+ */
+function startFocusEnforcement() {
+	setInterval(() => $('[forcefocus]').focus(), FOCUS_INTERVAL_MS);
 }
 }
 
 
-document.addEventListener('DOMContentLoaded', function () {
-
-	SetupConfirmationDialog();
-
+/**
+ * Initializes frontend interactions after the document is ready.
+ *
+ * @returns {void}
+ * @sideEffects Registers UI handlers and starts polling timers.
+ */
+function initializeFrontend() {
+	setupConfirmationDialog();
 	refreshNotificationArea();
 	refreshNotificationArea();
-
-	ForceFocus(); // INPUT AUTOFOCUS
+	startFocusEnforcement();
 
 
 	$('body').on('submit', 'form', function () {
 	$('body').on('submit', 'form', function () {
 		$('body').focus();
 		$('body').focus();
 		$('form input,form select').prop('readonly', true);
 		$('form input,form select').prop('readonly', true);
-
-		// Manual submit for more control
 		this.submit();
 		this.submit();
 		return false;
 		return false;
 	});
 	});
 
 
-	setInterval(() => {
-		refreshNotificationArea();
-	}, 3000);
-
-	//EditTableInit('.datatables', `${BASEURL}/pickinglistcontroller`);
+	setInterval(refreshNotificationArea, NOTIFICATION_REFRESH_INTERVAL_MS);
+	window.NotificationDelete = deleteNotification;
+}
 
 
-	window.NotificationDelete = NotificationDelete;
-});
+document.addEventListener('DOMContentLoaded', initializeFrontend);

+ 3 - 3
ui/frontend/login.tpl

@@ -7,10 +7,10 @@
 				{{@INFOTEXT}}
 				{{@INFOTEXT}}
 			</div>
 			</div>
 		</check>
 		</check>
-		<label class="sr-only" for="username">username</label>
+		<label class="visually-hidden" for="username">username</label>
 		<input autofocus autocomplete="off" class="form-control mb-1" id="username" name="username" placeholder="Username" required type="text">
 		<input autofocus autocomplete="off" class="form-control mb-1" id="username" name="username" placeholder="Username" required type="text">
-		<label class="sr-only" for="password">Password</label>
+		<label class="visually-hidden" for="password">Password</label>
 		<input autocomplete="off" class="form-control mb-1" id="password" name="password" placeholder="Password" required type="password">
 		<input autocomplete="off" class="form-control mb-1" id="password" name="password" placeholder="Password" required type="password">
-		<button class="btn btn-block" type="submit">Invia</button>
+		<button class="btn w-100" type="submit">Invia</button>
 	</form>
 	</form>
 </div>
 </div>

+ 2 - 0
ui/frontend/masterpage_template.tpl

@@ -9,6 +9,8 @@
   <script type="text/javascript">
   <script type="text/javascript">
     const BASEURL = '{{@BASEURL}}';
     const BASEURL = '{{@BASEURL}}';
   </script>
   </script>
+
+  <!-- VITE_ASSETS -->
 </head>
 </head>
 <body>
 <body>
 
 

+ 47 - 0
ui/shared/OMGUtils.js

@@ -0,0 +1,47 @@
+export const fetchHeaders = Object.freeze({
+	Accept: 'text/html',
+	'Content-Type': 'text/html',
+	'X-Requested-With': 'XMLHttpRequest',
+	'Cache-Control': 'no-cache',
+	Pragma: 'no-cache',
+	Expires: '0',
+});
+
+/**
+ * Creates an abortable AJAX request.
+ *
+ * @param {string} url Request endpoint.
+ * @param {RequestInit} options Fetch options.
+ * @returns {{fetchPromise: () => Promise<unknown>, abort: () => void}} Request controls.
+ * @sideEffects Starts a network request.
+ */
+export function AjaxReq(url, options = {}) {
+	const controller = new AbortController();
+	const headers = { ...fetchHeaders, ...options.headers };
+	if (options.body instanceof FormData) {
+		delete headers['Content-Type'];
+	}
+
+	const requestOptions = { ...options, headers, signal: controller.signal };
+	const fetchPromise = fetch(url, requestOptions).then(parseJsonResponse);
+
+	return {
+		fetchPromise: () => fetchPromise,
+		abort: () => controller.abort(),
+	};
+}
+
+/**
+ * Returns the JSON body of a successful HTTP response.
+ *
+ * @param {Response} response Fetch response.
+ * @returns {Promise<unknown>} Parsed response body.
+ * @throws {Response} When the response status is not successful.
+ */
+function parseJsonResponse(response) {
+	if (!response.ok) {
+		return Promise.reject(response);
+	}
+
+	return response.json();
+}

+ 6 - 0
ui/shared/jquery.js

@@ -0,0 +1,6 @@
+import jQuery from 'jquery';
+
+window.$ = jQuery;
+window.jQuery = jQuery;
+
+export default jQuery;

+ 36 - 0
vite.config.js

@@ -0,0 +1,36 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vite';
+import viteTemplatePlugin from './scripts/vite-template-plugin.mjs';
+
+const PROJECT_ROOT = path.dirname(fileURLToPath(import.meta.url));
+const FRONTEND_ENTRY = path.resolve(PROJECT_ROOT, 'ui/frontend/js/frontend.js');
+
+export default defineConfig({
+	root: PROJECT_ROOT,
+	base: '/phengine/assets/frontend/',
+	plugins: [
+		viteTemplatePlugin({
+			entry: FRONTEND_ENTRY,
+			templates: [
+				{
+					source: 'ui/frontend/masterpage_template.tpl',
+					output: 'ui/frontend/masterpage.tpl',
+				},
+				{
+					source: 'ui/frontend/errors_template.tpl',
+					output: 'ui/frontend/errors.tpl',
+				},
+			],
+		}),
+	],
+	build: {
+		outDir: path.resolve(PROJECT_ROOT, 'assets/frontend'),
+		emptyOutDir: true,
+		manifest: 'manifest.json',
+		sourcemap: false,
+		rollupOptions: {
+			input: FRONTEND_ENTRY,
+		},
+	},
+});

+ 17 - 10
webpack.config.js

@@ -6,10 +6,20 @@ const { CleanWebpackPlugin } = require("clean-webpack-plugin");
 const OptimizeCSSAssetsPlugin = require('css-minimizer-webpack-plugin');
 const OptimizeCSSAssetsPlugin = require('css-minimizer-webpack-plugin');
 const webpack = require("webpack");
 const webpack = require("webpack");
 
 
-function getCurrentProjectname() {
-	let pathSplit = __dirname.split(path.sep);
+const ASSET_INLINE_LIMIT = 100000;
+const TERSER_PARALLEL_WORKERS = 4;
+const PROJECT_NAME_PATH_OFFSET = -1;
 
 
-	return pathSplit[pathSplit.length - 1];
+/**
+ * Gets the project name from the webpack configuration directory.
+ *
+ * @returns {string} The project directory name.
+ * @sideEffects None.
+ */
+function getCurrentProjectName() {
+	const pathParts = __dirname.split(path.sep);
+
+	return pathParts[pathParts.length + PROJECT_NAME_PATH_OFFSET];
 }
 }
 
 
 module.exports = {
 module.exports = {
@@ -42,20 +52,19 @@ module.exports = {
 	module: {
 	module: {
 		rules: [
 		rules: [
 			{
 			{
-				// For css...
 				test: /\.(css|scss)$/,
 				test: /\.(css|scss)$/,
-				// ...use this loaders
 				use: [
 				use: [
 					{ loader: MiniCssExtractPlugin.loader },
 					{ loader: MiniCssExtractPlugin.loader },
 					{ loader: "css-loader" },
 					{ loader: "css-loader" },
 					{ loader: "sass-loader" }
 					{ loader: "sass-loader" }
 				]
 				]
 			},
 			},
+
 			// Import various assets
 			// Import various assets
 			{
 			{
 				test: /\.(jpg|png|woff|woff2|eot|ttf|svg)$/,
 				test: /\.(jpg|png|woff|woff2|eot|ttf|svg)$/,
 				use: [
 				use: [
-					{ loader: 'url-loader', options: '?limit=100000' }
+					{ loader: 'url-loader', options: `?limit=${ASSET_INLINE_LIMIT}` }
 				]
 				]
 			}
 			}
 		]
 		]
@@ -63,11 +72,9 @@ module.exports = {
 	optimization: {
 	optimization: {
 		minimizer: [
 		minimizer: [
 			new TerserJSPlugin(
 			new TerserJSPlugin(
-				// Discard Comments
-				{ terserOptions: { output: { comments: false } }, parallel: 4 }
+				{ terserOptions: { output: { comments: false } }, parallel: TERSER_PARALLEL_WORKERS }
 			),
 			),
 			new OptimizeCSSAssetsPlugin(
 			new OptimizeCSSAssetsPlugin(
-				// Discard Comments
 				{ minimizerOptions: { preset: ['default', { discardComments: { removeAll: true } }] } }
 				{ minimizerOptions: { preset: ['default', { discardComments: { removeAll: true } }] } }
 			)
 			)
 		],
 		],
@@ -76,7 +83,7 @@ module.exports = {
 		"frontend": "./ui/frontend/js/frontend.js"
 		"frontend": "./ui/frontend/js/frontend.js"
 	},
 	},
 	output: {
 	output: {
-		publicPath: '/' + getCurrentProjectname() + '/assets/frontend/',
+		publicPath: '/' + getCurrentProjectName() + '/assets/frontend/',
 		path: path.resolve(__dirname, "assets/frontend/"),
 		path: path.resolve(__dirname, "assets/frontend/"),
 		filename: "[name].[contenthash].js"
 		filename: "[name].[contenthash].js"
 	},
 	},