5 Commits

Author SHA1 Message Date
boris
d027e01ccc fixed centering issue with radius.
Signed-off-by: boris <boris@borishub.co.uk>
2025-04-22 01:17:48 +01:00
boris
183cca3fd3 pre-clean x2
Signed-off-by: boris <boris@borishub.co.uk>
2025-04-21 23:02:08 +01:00
boris
8877faa631 pre-clean
Signed-off-by: boris <boris@borishub.co.uk>
2025-04-21 21:24:46 +01:00
boris
78508a7cbd erm
Signed-off-by: boris <boris@borishub.co.uk>
2025-04-20 16:49:23 +01:00
boris
709596eea2 i finally committed i guess
Signed-off-by: boris <boris@borishub.co.uk>
2025-03-15 01:59:16 +00:00
102 changed files with 6376 additions and 54449 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# JWT Configuration
JWT_SECRET_KEY=your-secret-key-here
JWT_TOKEN_EXPIRY=3600 # 1 hour in seconds
# Database Configuration
DB_HOST=localhost
DB_NAME=your_database_name
DB_USER=your_database_user
DB_PASS=your_database_password

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Environment variables
.env
.env.local
.env.*.local
# IDE files
.idea/
.vscode/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Generated Files
generate*.*
user_credentials.txt
add_facilities.py
facility_generation_log.txt

1
.idea/Ecobuddy.iml generated
View File

@@ -4,5 +4,6 @@
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="leaflet" level="application" />
</component>
</module>

28
.idea/dataSources.xml generated
View File

@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="ecobuddy" uuid="b932ada6-ed77-47fa-96d8-d6dfa86a6ca2">
<data-source source="LOCAL" name="ecobuddynew.sqlite" uuid="6566010b-b220-4baf-bb3e-99178c3287f0">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/Databases/ecobuddy.sqlite</jdbc-url>
<jdbc-url>jdbc:sqlite:Databases/ecobuddynew.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="ecobuddynew" uuid="b5d0338c-4f7c-4008-ba23-032fa68749c1">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/Databases/ecobuddynew.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
@@ -14,13 +21,26 @@
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
</library>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
</library>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
</library>
</libraries>
</data-source>
<data-source source="LOCAL" name="ecobuddynew.sqlite" uuid="6566010b-b220-4baf-bb3e-99178c3287f0">
<data-source source="LOCAL" name="ecobuddy.sqlite" uuid="5216c958-85d2-48a7-b57e-256771f5c73c">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:Databases/ecobuddynew.sqlite</jdbc-url>
<jdbc-url>jdbc:sqlite:Databases/ecobuddy.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="ecobuddyupdated.sqlite" uuid="a451dcaa-33f3-4c5d-9b63-c111bb5ed2fb">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:Databases/ecobuddyupdated.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="PROJECT" libraries="{jquery}" />
<file url="PROJECT" libraries="{leaflet}" />
</component>
</project>

2
.idea/php.xml generated
View File

@@ -10,7 +10,7 @@
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="7.1" />
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>

0
.idea/sqldialects.xml generated Normal file → Executable file
View File

BIN
Databases/ecobuddy.sqlite Executable file → Normal file

Binary file not shown.

Binary file not shown.

202
Models/AuthService.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
require_once('UserDataSet.php');
/**
* Backend Authentication service for handling JWT authentication
* https://jwt.io/introduction
* This cost me blood, sweat and tears, mostly tears.
*/
class AuthService {
private string $secretKey;
private int $tokenExpiry;
/**
* Initialises the authentication service
* Loads configuration from environment variables
* @throws Exception if OpenSSL extension is not loaded
*/
public function __construct() {
// Load environment variables from .env file (:D more configuration needs to be added to .env, but scope creep already huge)
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Skip comments
if (strpos($line, '#') === 0) continue;
// Parse environment variable
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
if (!empty($name)) {
putenv(sprintf('%s=%s', $name, $value));
}
}
}
// Set configuration from environment variables with defaults
$this->secretKey = getenv('JWT_SECRET_KEY') ?: 'your-256-bit-secret';
$this->tokenExpiry = (int)(getenv('JWT_TOKEN_EXPIRY') ?: 3600);
// Verify OpenSSL extension is available. This should be on by default regardless, but just in case.
if (!extension_loaded('openssl')) {
throw new Exception('OpenSSL extension is required for JWT');
}
}
/**
* Generates a JWT token
* @param array $userData User information to include in token
* @return string The generated JWT token
*/
public function generateToken(array $userData): string {
$issuedAt = time();
$expire = $issuedAt + $this->tokenExpiry;
// Create payload with user data
$payload = [
'iat' => $issuedAt,
'exp' => $expire,
'uid' => $userData['id'],
'username' => $userData['username'],
'accessLevel' => $userData['userType']
];
return $this->encodeJWT($payload);
}
/**
* Validates a JWT token
* @param string $token The JWT token to validate
* @return array|null The decoded payload if valid, null otherwise
*/
public function validateToken(string $token): ?array {
try {
$payload = $this->decodeJWT($token);
// Check if token is expired
if ($payload === null || !isset($payload['exp']) || $payload['exp'] < time()) {
return null;
}
return $payload;
} catch (Exception $e) {
return null;
}
}
/**
* Encodes data into a JWT token
* @param array $payload The data to encode
* @return string The encoded JWT token
*/
private function encodeJWT(array $payload): string {
// Create and encode header
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$header = $this->base64UrlEncode($header);
// Create and encode payload
$payload = json_encode($payload);
$payload = $this->base64UrlEncode($payload);
// Create and encode signature
$signature = hash_hmac('sha256', "$header.$payload", $this->secretKey, true);
$signature = $this->base64UrlEncode($signature);
return "$header.$payload.$signature"; //Wooooooo!!! JWT is a thing!
}
/**
* Decodes a JWT token
* @param string $token The JWT token to decode
* @return array|null The decoded payload if valid, null otherwise
*/
private function decodeJWT(string $token): ?array {
// Split token into components
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header, $payload, $signature] = $parts;
// Verify signature
$validSignature = $this->base64UrlEncode(
hash_hmac('sha256', "$header.$payload", $this->secretKey, true)
);
if ($signature !== $validSignature) {
return null;
}
// Decode and return payload
return json_decode($this->base64UrlDecode($payload), true);
}
/**
* Encodes data using base64url encoding
* @param string $data The data to encode
* @return string The encoded data
*/
private function base64UrlEncode(string $data): string {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Decodes base64url encoded data
* @param string $data The data to decode
* @return string The decoded data
*/
private function base64UrlDecode(string $data): string {
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', 3 - (3 + strlen($data)) % 4));
}
/**
* Generates a refresh token for a user
* @param array $userData User information to include in token
* @return string The generated refresh token
*/
public function generateRefreshToken(array $userData): string {
$issuedAt = time();
$expire = $issuedAt + ($this->tokenExpiry * 24); // Refresh token lasts 24 times longer than access token
$payload = [
'iat' => $issuedAt,
'exp' => $expire,
'uid' => $userData['id'],
'username' => $userData['username'],
'type' => 'refresh'
];
return $this->encodeJWT($payload);
}
/**
* Refreshes an access token using a refresh token
* @param string $refreshToken The refresh token
* @return string|null The new access token if valid, null otherwise
*/
public function refreshToken(string $refreshToken): ?string {
try {
$payload = $this->decodeJWT($refreshToken);
// Check if token is expired or not a refresh token
if ($payload === null || !isset($payload['exp']) || $payload['exp'] < time() ||
!isset($payload['type']) || $payload['type'] !== 'refresh') {
return null;
}
// Generate a new access token
$userData = [
'id' => $payload['uid'],
'username' => $payload['username'],
'userType' => isset($payload['accessLevel']) ? $payload['accessLevel'] : 0
];
return $this->generateToken($userData);
} catch (Exception $e) {
return null;
}
}
}

43
Models/Database.php Normal file → Executable file
View File

@@ -1,18 +1,31 @@
<?php
/**
* Database connection handler using Singleton pattern
*/
class Database {
/**
* @var Database
* @var Database|null The singleton instance
*/
protected static $_dbInstance = null;
/**
* @var PDO
* @var PDO The database connection handle
*/
protected $_dbHandle;
/**
* Gets the database connection handle
* @return PDO The database connection
*/
public function getDbConnection(): PDO
{
return $this->_dbHandle;
}
/**
* Gets the singleton instance of the Database class
* @return Database The database instance
*/
public static function getInstance(): ?Database
{
if(self::$_dbInstance == null) {
@@ -21,17 +34,37 @@ class Database {
return self::$_dbInstance;
}
/**
* Private constructor to prevent direct instantiation
* Initialises the database connection
* @throws PDOException if connection fails
*/
private function __construct() {
try {
$this->_dbHandle = new PDO("sqlite:Databases/ecobuddynew.sqlite");
// Create PDO connection with error handling
$this->_dbHandle = new PDO("sqlite:Databases/ecobuddy.sqlite");
// Configure PDO for better error handling and performance
$this->_dbHandle->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
$this->_dbHandle->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// SQLite3 sometimes just forgets foreign keys exist i guess (https://stackoverflow.com/questions/15301643/sqlite3-forgets-to-use-foreign-keys)
$this->_dbHandle->exec('PRAGMA foreign_keys = ON;');
// Set transaction timeout to 5 seconds, just stops the app from hanging when the db is busy
$this->_dbHandle->exec('PRAGMA busy_timeout = 5000;');
}
catch (PDOException $e) {
echo $e->getMessage();
// Log the error and rethrow
error_log("Database connection error: " . $e->getMessage());
throw $e;
}
}
/**
* Destructor to clean up database connection
*/
public function __destruct() {
$this->_dbHandle = null; // destroys the PDO handle when no longer needed
$this->_dbHandle = null;
}
}

110
Models/FacilityData.php Normal file → Executable file
View File

@@ -1,7 +1,48 @@
<?php
/**
* Represents a singular facility
*
* Data model for facilities, encapsulating all
* properties and behaviours of a single facility.
*
* Each facility has location data, descriptive info, and metadata.
*/
class FacilityData {
protected $_id, $_title, $_category, $_status, $_description, $_houseNumber, $_streetName, $_county, $_town, $_postcode, $_lng, $_lat, $_contributor;
/**
* Facility properties
*
* @var int $_id - Unique identifier for the facility
* @var string $_title - Name of the facility
* @var string $_category - Category/type of the facility
* @var string $_status - Current status of the facility
* @var string $_description - Detailed description of the facility
* @var string $_houseNumber - Building number or name
* @var string $_streetName - Street name
* @var string $_county - County
* @var string $_town - Town or city
* @var string $_postcode - Postal code
* @var float $_lng - Longitude coordinate
* @var float $_lat - Latitude coordinate
* @var string $_contributor - Username of the person who added the facility
*/
protected $_id;
protected $_title;
protected $_category;
protected $_status;
protected $_description;
protected $_houseNumber;
protected $_streetName;
protected $_county;
protected $_town;
protected $_postcode;
protected $_lng;
protected $_lat;
protected $_contributor;
/**
* Initialises a new facility with data from the database
* @param array $dbRow Database row containing facility data
*/
public function __construct($dbRow) {
$this->_id = $dbRow['id'];
$this->_title = $dbRow['title'];
@@ -18,43 +59,110 @@ class FacilityData {
$this->_contributor = $dbRow['contributor'];
}
/**
* Gets the facility's unique identifier
*
* This ID is used throughout the application to reference this specific
* facility, particularly in database operations and API requests.
*
* @return int The facility ID
*/
public function getId() {
return $this->_id;
}
/**
* Gets the facility's title
* @return string The facility title
*/
public function getTitle() {
return $this->_title;
}
/**
* Gets the facility's category
* @return string The facility category
*/
public function getCategory() {
return $this->_category;
}
/**
* Gets the facility's current status
* @return string The facility status
*/
public function getStatus() {
return $this->_status;
}
/**
* Gets the facility's description
* @return string The facility description
*/
public function getDescription() {
return $this->_description;
}
/**
* Gets the facility's house/building number
* @return string The house/building number
*/
public function getHouseNumber() {
return $this->_houseNumber;
}
/**
* Gets the facility's street name
* @return string The street name
*/
public function getStreetName() {
return $this->_streetName;
}
/**
* Gets the facility's county
* @return string The county
*/
public function getCounty() {
return $this->_county;
}
/**
* Gets the facility's town or city
* @return string The town or city
*/
public function getTown() {
return $this->_town;
}
/**
* Gets the facility's postcode
* @return string The postcode
*/
public function getPostcode() {
return $this->_postcode;
}
/**
* Gets the facility's longitude coordinate
* @return float The longitude coordinate
*/
public function getLng() {
return $this->_lng;
}
/**
* Gets the facility's latitude coordinate
* @return float The latitude coordinate
*/
public function getLat() {
return $this->_lat;
}
/**
* Gets the username of the facility's contributor
* @return string The contributor's username
*/
public function getContributor() {
return $this->_contributor;
}

586
Models/FacilityDataSet.php Normal file → Executable file
View File

@@ -12,76 +12,6 @@ class FacilityDataSet
$this->_dbHandle = $this->_dbInstance->getDbConnection();
}
/**
* @param $data
* @return bool
* Broken last minute, dont have time to fix.
* add / update facility to database from array of columns
*/
public function addFacility($data): bool
{
$userQuery = "
SELECT ecoUser.id FROM ecoUser
WHERE ecoUser.username = :contributor;
";
$catQuery = "
SELECT ecoCategories.id FROM ecoCategories
WHERE ecoCategories.name = :category;
";
$sqlQuery = "
INSERT OR REPLACE INTO ecoFacilities
(id,
title,
category,
description,
houseNumber,
streetName,
county,
town,
postcode,
lng,
lat,
contributor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, -1, -1, ?)
;";
// gets contributor name
$stmt = $this->_dbHandle->prepare($userQuery);
$stmt->bindParam(':contributor', $data->contributor, PDO::PARAM_STR);
$stmt = $this->_dbHandle->prepare($userQuery);
$stmt->execute();
$data['contributor'] = (int)$stmt->fetch(PDO::FETCH_ASSOC);
// gets category ID
$stmt = $this->_dbHandle->prepare($catQuery);
$stmt->bindParam(':category', $data->category, PDO::PARAM_STR);
$stmt = $this->_dbHandle->prepare($catQuery);
$stmt->execute();
$data['category'] = (int)$stmt->fetch(PDO::FETCH_ASSOC);
// run main query and bind updated parameters
$stmt = $this->_dbHandle->prepare($sqlQuery);
// Ensures only one value is returned per column name
$stmt->setFetchMode(\PDO::FETCH_ASSOC);
if (isset($data['id'])) {
$stmt->bindParam(1, $data['id']);
}
$stmt->bindParam(2, $data['title'], PDO::PARAM_STR);
$stmt->bindParam(3, $data['category'], PDO::PARAM_INT);
$stmt->bindParam(4, $data['description'], PDO::PARAM_STR);
$stmt->bindParam(5, $data['houseNumber'], PDO::PARAM_STR);
$stmt->bindParam(6, $data['streetName'], PDO::PARAM_STR);
$stmt->bindParam(7, $data['county'], PDO::PARAM_STR);
$stmt->bindParam(8, $data['town'], PDO::PARAM_STR);
$stmt->bindParam(9, $data['postcode'], PDO::PARAM_STR);
$stmt->bindParam(10, $data['contributor'], PDO::PARAM_INT);
$stmt->execute();
// var_dump($stmt);
// var_dump($this->_dbHandle->errorInfo());
return !($stmt->rowCount());
}
/**
* @param $id
* @return bool
@@ -89,114 +19,444 @@ class FacilityDataSet
*/
public function deleteFacility($id): bool
{
$sqlQuery = "DELETE FROM ecoFacilities WHERE ecoFacilities.id = :id;";
$stmt = $this->_dbHandle->prepare($sqlQuery);
$stmt->bindValue(':id', (int)$id, \PDO::PARAM_INT);
$stmt->execute();
var_dump($stmt);
echo $stmt->rowCount();
return !($stmt->rowCount() == 0);
try {
// Start transaction
$this->_dbHandle->beginTransaction();
// Delete related status records first
$statusQuery = "DELETE FROM ecoFacilityStatus WHERE facilityid = :id;";
$statusStmt = $this->_dbHandle->prepare($statusQuery);
$statusStmt->bindValue(':id', (int)$id, \PDO::PARAM_INT);
$statusStmt->execute();
// Delete the facility
$facilityQuery = "DELETE FROM ecoFacilities WHERE id = :id;";
$facilityStmt = $this->_dbHandle->prepare($facilityQuery);
$facilityStmt->bindValue(':id', (int)$id, \PDO::PARAM_INT);
$facilityStmt->execute();
// Commit transaction
$this->_dbHandle->commit();
return $facilityStmt->rowCount() > 0;
} catch (PDOException $e) {
// Rollback on error
$this->_dbHandle->rollBack();
error_log("Error deleting facility: " . $e->getMessage());
return false;
}
}
/**
* @return array|false Returns array of facilities or false on error
* Fetch all facility records with related data
*/
public function fetchAll(): array|false
{
try {
error_log('Starting fetchAll...');
$query = "
SELECT DISTINCT ecoFacilities.id,
ecoFacilities.title,
COALESCE(GROUP_CONCAT(ecoFacilityStatus.statusComment, '; '), '') AS status,
ecoCategories.name AS category,
ecoFacilities.description,
ecoFacilities.houseNumber,
ecoFacilities.streetName,
ecoFacilities.county,
ecoFacilities.town,
ecoFacilities.postcode,
ecoFacilities.lng,
ecoFacilities.lat,
COALESCE(ecoUser.username, 'Unknown') AS contributor
FROM ecoFacilities
LEFT JOIN ecoCategories ON ecoCategories.id = ecoFacilities.category
LEFT JOIN ecoUser ON ecoUser.id = ecoFacilities.contributor
LEFT JOIN ecoFacilityStatus ON ecoFacilityStatus.facilityid = ecoFacilities.id
GROUP BY ecoFacilities.id, ecoFacilities.title, ecoCategories.name,
ecoFacilities.description, ecoFacilities.streetName,
ecoFacilities.county, ecoFacilities.town, ecoFacilities.postcode,
ecoUser.username
ORDER BY ecoFacilities.id ASC;
";
error_log('Preparing query...');
$dataStmt = $this->_dbHandle->prepare($query);
error_log('Executing query...');
$dataStmt->execute();
error_log('Fetching results...');
$results = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
if ($results === false) {
error_log('Query returned false');
return false;
}
error_log('Query successful. Row count: ' . count($results));
return $results;
} catch (PDOException $e) {
error_log("Database error in fetchAll: " . $e->getMessage());
error_log("SQL State: " . $e->getCode());
error_log("Stack trace: " . $e->getTraceAsString());
return false;
} catch (Exception $e) {
error_log("General error in fetchAll: " . $e->getMessage());
error_log("Stack trace: " . $e->getTraceAsString());
return false;
}
}
/**
* @param $filterArray
* @param $sortArray
* @return array
* Fetch all records depending on filters, and sort by defined column
* Creates a new facility in the database
* @param array $data Facility data
* @return array|false The created facility data or false on failure
*/
public function fetchAll($filterArray, $sortArray): array
public function createFacility($data)
{
// Define columns for filtering and sorting
$filterColumns = [
0 => 'ecoFacilityStatus.statusComment',
1 => 'ecoFacilities.title',
2 => 'ecoCategories.name',
3 => 'ecoFacilities.description',
4 => 'ecoFacilities.streetName',
5 => 'ecoFacilities.county',
6 => 'ecoFacilities.town',
7 => 'ecoFacilities.postcode',
8 => 'ecoUser.username'
];
try {
$this->_dbHandle->beginTransaction();
$sortColumns = [
0 => 'ecoFacilityStatus.statusComment',
1 => 'ecoFacilities.title',
2 => 'ecoCategories.name',
3 => 'ecoFacilities.description',
4 => 'ecoFacilities.streetName',
5 => 'ecoFacilities.county',
6 => 'ecoFacilities.town',
7 => 'ecoFacilities.postcode',
8 => 'ecoUser.username'
];
// Validate coordinates
if (!is_numeric($data['lng']) || !is_numeric($data['lat']) ||
$data['lng'] < -180 || $data['lng'] > 180 ||
$data['lat'] < -90 || $data['lat'] > 90) {
throw new Exception('Invalid coordinates provided');
}
// Validate and select the filter column
$selectedFilterColumn = $filterColumns[$filterArray['category']] ?? 'ecoFacilities.title';
// Validate and select the sort column
$selectedSortColumn = $sortColumns[$sortArray['sort']] ?? 'ecoFacilities.title';
// Validate sort direction
$direction = strtolower($sortArray['dir']) === 'desc' ? 'DESC' : 'ASC';
// Base query for filtering and sorting
$baseQuery = "
FROM ecoFacilities
LEFT JOIN ecoCategories ON ecoCategories.id = ecoFacilities.category
LEFT JOIN ecoUser ON ecoUser.id = ecoFacilities.contributor
LEFT JOIN ecoFacilityStatus ON ecoFacilityStatus.facilityid = ecoFacilities.id
WHERE {$selectedFilterColumn} LIKE :term
";
// Get contributor ID
$contributorId = $this->getContributorId($data['contributor']);
if (!$contributorId) {
throw new Exception('Invalid contributor name');
}
// Get category ID
$categoryId = $this->getCategoryId($data['category']);
if (!$categoryId) {
// If category doesn't exist, create it
$categoryId = $this->createCategory($data['category']);
if (!$categoryId) {
throw new Exception('Failed to create category: ' . $data['category']);
}
}
// Query to count total results
$countQuery = "SELECT COUNT(DISTINCT ecoFacilities.id) AS total {$baseQuery}";
// Insert facility
$sql = "INSERT INTO ecoFacilities (title, category, description, houseNumber,
streetName, county, town, postcode, lng, lat, contributor)
VALUES (:title, :category, :description, :houseNumber,
:streetName, :county, :town, :postcode, :longitude, :latitude, :contributor)";
$stmt = $this->_dbHandle->prepare($sql);
$params = [
':title' => $data['title'],
':category' => $categoryId,
':description' => $data['description'],
':houseNumber' => $data['houseNumber'],
':streetName' => $data['streetName'],
':county' => $data['county'],
':town' => $data['town'],
':postcode' => $data['postcode'],
':longitude' => $data['lng'],
':latitude' => $data['lat'],
':contributor' => $contributorId
];
// Query to fetch filtered and sorted results
$dataQuery = "
SELECT DISTINCT ecoFacilities.id,
ecoFacilities.title,
GROUP_CONCAT(ecoFacilityStatus.statusComment, ', ') AS status,
ecoCategories.name AS category,
ecoFacilities.description,
ecoFacilities.houseNumber,
ecoFacilities.streetName,
ecoFacilities.county,
ecoFacilities.town,
ecoFacilities.postcode,
ecoFacilities.lng,
ecoFacilities.lat,
ecoUser.username AS contributor
{$baseQuery}
GROUP BY ecoFacilities.id, ecoFacilities.title, ecoCategories.name,
ecoFacilities.description, ecoFacilities.streetName,
ecoFacilities.county, ecoFacilities.town, ecoFacilities.postcode,
ecoUser.username
ORDER BY {$selectedSortColumn} {$direction};
";
// Surround 'term' with % to allow usage with LIKE
$filterArray['term'] = '%' . $filterArray['term'] . '%' ?? '%';
// Prepare and execute the count query
$countStmt = $this->_dbHandle->prepare($countQuery);
$countStmt->bindValue(':term', $filterArray['term'], PDO::PARAM_STR);
$countStmt->execute();
// Set total results to output of count statement
$totalResults = (int)$countStmt->fetchColumn();
error_log("Executing SQL with params: " . print_r($params, true));
if (!$stmt->execute($params)) {
throw new Exception('Failed to insert facility: ' . implode(', ', $stmt->errorInfo()));
}
// Prepare and execute the data query
$dataStmt = $this->_dbHandle->prepare($dataQuery);
$dataStmt->bindValue(':term', $filterArray['term'], PDO::PARAM_STR);
$dataStmt->execute();
$facilityId = $this->_dbHandle->lastInsertId();
$this->_dbHandle->commit();
// Fetch results into FacilityData objects
$dataSet = [];
while ($row = $dataStmt->fetch()) {
$dataSet[] = new FacilityData($row);
// Return the created facility
return $this->getFacilityById($facilityId);
} catch (Exception $e) {
$this->_dbHandle->rollBack();
error_log("Error in createFacility: " . $e->getMessage());
throw $e;
}
}
return [
'dataset' => $dataSet,
'count' => $totalResults
];
private function createCategory($categoryName)
{
try {
$sql = "INSERT INTO ecoCategories (name) VALUES (:name)";
$stmt = $this->_dbHandle->prepare($sql);
$stmt->execute([':name' => $categoryName]);
return $this->_dbHandle->lastInsertId();
} catch (Exception $e) {
error_log("Error creating category: " . $e->getMessage());
return false;
}
}
/**
* Updates an existing facility in the database
* @param int $id Facility ID
* @param array $data Updated facility data
* @return array|false The updated facility data or false on failure
*/
public function updateFacility($id, $data) {
try {
// Start transaction
$this->_dbHandle->beginTransaction();
// Validate coordinates
if (!is_numeric($data['lng']) || !is_numeric($data['lat']) ||
$data['lng'] < -180 || $data['lng'] > 180 ||
$data['lat'] < -90 || $data['lat'] > 90) {
throw new Exception('Invalid coordinates');
}
// Get Contributor ID
$query = "SELECT ecoUser.id FROM ecoUser WHERE ecoUser.username = :contributor;";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':contributor', $data['contributor']);
$stmt->execute();
$contributorResult = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contributorResult) {
throw new Exception('Invalid contributor username');
}
$contributorId = $contributorResult['id'];
// Get Category ID
$query = "SELECT ecoCategories.id FROM ecoCategories WHERE ecoCategories.name = :category;";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':category', $data['category']);
$stmt->execute();
$categoryResult = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$categoryResult) {
throw new Exception('Invalid category name');
}
$categoryId = $categoryResult['id'];
// Update facility
$query = "
UPDATE ecoFacilities
SET title = :title,
category = :category,
description = :description,
houseNumber = :houseNumber,
streetName = :streetName,
county = :county,
town = :town,
postcode = :postcode,
lng = :lng,
lat = :lat,
contributor = :contributor
WHERE id = :id
";
$stmt = $this->_dbHandle->prepare($query);
$params = [
':title' => $data['title'],
':category' => $categoryId,
':description' => $data['description'],
':houseNumber' => $data['houseNumber'],
':streetName' => $data['streetName'],
':county' => $data['county'],
':town' => $data['town'],
':postcode' => $data['postcode'],
':lng' => $data['lng'],
':lat' => $data['lat'],
':contributor' => $contributorId,
':id' => $id
];
error_log("Executing update query with params: " . print_r($params, true));
if (!$stmt->execute($params)) {
throw new Exception('Failed to update facility: ' . implode(', ', $stmt->errorInfo()));
}
if ($stmt->rowCount() > 0) {
$this->_dbHandle->commit();
return $this->getFacilityById($id);
}
$this->_dbHandle->rollBack();
return false;
} catch (Exception $e) {
$this->_dbHandle->rollBack();
error_log("Error updating facility: " . $e->getMessage());
return false;
}
}
/**
* Gets a facility by its ID
* @param int $id Facility ID
* @return array|false The facility data or false if not found
*/
public function getFacilityById($id) {
try {
$query = "
SELECT DISTINCT ecoFacilities.id,
ecoFacilities.title,
COALESCE(GROUP_CONCAT(ecoFacilityStatus.statusComment, ';'), '') AS status,
ecoCategories.name AS category,
ecoFacilities.description,
ecoFacilities.houseNumber,
ecoFacilities.streetName,
ecoFacilities.county,
ecoFacilities.town,
ecoFacilities.postcode,
ecoFacilities.lng,
ecoFacilities.lat,
COALESCE(ecoUser.username, 'Unknown') AS contributor
FROM ecoFacilities
LEFT JOIN ecoCategories ON ecoCategories.id = ecoFacilities.category
LEFT JOIN ecoUser ON ecoUser.id = ecoFacilities.contributor
LEFT JOIN ecoFacilityStatus ON ecoFacilityStatus.facilityid = ecoFacilities.id
WHERE ecoFacilities.id = ?
GROUP BY ecoFacilities.id, ecoFacilities.title, ecoCategories.name,
ecoFacilities.description, ecoFacilities.streetName,
ecoFacilities.county, ecoFacilities.town, ecoFacilities.postcode,
ecoUser.username;
";
$stmt = $this->_dbHandle->prepare($query);
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log("Error getting facility: " . $e->getMessage());
return false;
}
}
private function getContributorId($username)
{
try {
$query = "SELECT ecoUser.id FROM ecoUser WHERE ecoUser.username = :username;";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':username', $username);
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? $result['id'] : false;
} catch (Exception $e) {
error_log("Error getting contributor ID: " . $e->getMessage());
return false;
}
}
private function getCategoryId($categoryName)
{
try {
$query = "SELECT ecoCategories.id FROM ecoCategories WHERE ecoCategories.name = :name;";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':name', $categoryName);
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? $result['id'] : false;
} catch (Exception $e) {
error_log("Error getting category ID: " . $e->getMessage());
return false;
}
}
/**
* Adds a new status comment to a facility
* @param int $facilityId The ID of the facility
* @param string $statusComment The status comment to add
* @return bool True if successful, false otherwise
*/
public function addFacilityStatus($facilityId, $statusComment)
{
try {
// Log input parameters
error_log("Adding facility status - Facility ID: " . $facilityId . ", Comment: " . $statusComment);
// Start transaction
$this->_dbHandle->beginTransaction();
// Insert new status comment
$query = "INSERT INTO ecoFacilityStatus (facilityId, statusComment) VALUES (:facilityId, :statusComment)";
$stmt = $this->_dbHandle->prepare($query);
// Log the prepared statement
error_log("Prepared statement: " . $query);
// Bind values and log them
$stmt->bindValue(':facilityId', (int)$facilityId, PDO::PARAM_INT);
$stmt->bindValue(':statusComment', $statusComment);
error_log("Bound values - Facility ID: " . (int)$facilityId . ", Comment: " . $statusComment);
if (!$stmt->execute()) {
$errorInfo = $stmt->errorInfo();
error_log("SQL Error: " . print_r($errorInfo, true));
throw new Exception('Failed to insert status comment: ' . implode(', ', $errorInfo));
}
$this->_dbHandle->commit();
error_log("Successfully added facility status");
return true;
} catch (Exception $e) {
$this->_dbHandle->rollBack();
error_log("Error adding facility status: " . $e->getMessage());
error_log("Stack trace: " . $e->getTraceAsString());
return false;
}
}
/**
* Gets all status comments for a facility
* @param int $facilityId The ID of the facility
* @return array Array of status comments with their IDs
*/
public function getFacilityStatuses($facilityId)
{
try {
$query = "SELECT id, statusComment FROM ecoFacilityStatus WHERE facilityId = :facilityId ORDER BY id DESC";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':facilityId', (int)$facilityId, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
error_log("Error getting facility statuses: " . $e->getMessage());
return [];
}
}
/**
* Updates an existing status comment
* @param int $statusId The ID of the status comment
* @param string $statusComment The updated status comment
* @return bool True if successful, false otherwise
*/
public function updateFacilityStatus($statusId, $statusComment)
{
try {
$query = "UPDATE ecoFacilityStatus SET statusComment = :statusComment WHERE id = :statusId";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':statusId', (int)$statusId, PDO::PARAM_INT);
$stmt->bindValue(':statusComment', $statusComment);
return $stmt->execute();
} catch (Exception $e) {
error_log("Error updating facility status: " . $e->getMessage());
return false;
}
}
/**
* Deletes a specific status comment
* @param int $statusId The ID of the status comment to delete
* @return bool True if successful, false otherwise
*/
public function deleteFacilityStatus($statusId)
{
try {
$query = "DELETE FROM ecoFacilityStatus WHERE id = :statusId";
$stmt = $this->_dbHandle->prepare($query);
$stmt->bindValue(':statusId', (int)$statusId, PDO::PARAM_INT);
return $stmt->execute();
} catch (Exception $e) {
error_log("Error deleting facility status: " . $e->getMessage());
return false;
}
}
}

View File

@@ -1,57 +0,0 @@
<?php
require_once('FacilityDataSet.php');
class Paginator {
protected $_pages, $_totalPages, $_rowLimit, $_pageMatrix, $_rowCount;
public function __construct($rowLimit, $dataset) {
$this->_rowLimit = $rowLimit;
$this->_totalPages = $this->calculateTotalPages($dataset['count']);
$this->_rowCount = $dataset['count'];
$this->_pages = $dataset['dataset'];
$this->_pageMatrix = $this->Paginate();
}
public function getTotalPages() {
return $this->_totalPages;
}
private function calculateTotalPages(int $count): int {
return $count > 0 ? ceil($count / $this->_rowLimit) : 0;
}
public function Paginate(): array {
$pageMatrix = [];
for ($i = 0; $i < $this->_totalPages; $i++) {
$page = [];
$start = $i * $this->_rowLimit;
$end = min($start + $this->_rowLimit, $this->_rowCount); // Ensure within bounds
for ($j = $start; $j < $end; $j++) {
$page[] = $this->_pages[$j];
}
$pageMatrix[$i] = $page;
}
return $pageMatrix;
}
public function getPageFromUri(): int {
// Retrieve 'page' parameter and default to 0 if missing or invalid
return filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, [
'options' => ['default' => 0, 'min_range' => 0] // Default to 1 if invalid or missing
]);
}
public function getPage(int $pageNumber): array {
if ($pageNumber < 0 || $pageNumber >= $this->_totalPages) {
return []; // Return an empty array if the page number is invalid
}
return $this->_pageMatrix[$pageNumber];
}
public function countPageResults(int $pageNumber): int {
if ($pageNumber < 0 || $pageNumber >= $this->_totalPages) {
return 0; // Return 0 if the page number is invalid
}
return count($this->_pageMatrix[$pageNumber]);
}
}

174
Models/User.php Normal file → Executable file
View File

@@ -1,90 +1,176 @@
<?php
require_once('UserDataSet.php');
class User {
protected $_username, $_loggedIn, $_userId, $_accessLevel;
require_once('AuthService.php');
/**
* User class - Handles user authentication and session management
*
* This class manages user authentication using JWT tokens and provides
* methods for logging in, logging out, and checking user permissions.
* I've implemented this based on JWT authentication
*/
class User {
/**
* Class properties
* @var string $_username - The user's username
* @var bool $_loggedIn - Whether the user is currently logged in
* @var string $_userId - The user's unique ID
* @var int $_accessLevel - The user's access level (admin = 1, regular user = 2)
* @var AuthService $_authService - Service for JWT token handling
*/
protected $_username, $_loggedIn, $_userId, $_accessLevel;
protected $_authService;
/**
* Gets the current user's username
*
* @return string The username of the current user
*/
public function getUsername() {
return $this->_username;
}
/**
* Gets the current user's ID
*
* @return string The ID of the current user
*/
public function getUserId() {
return $this->_userId;
}
/**
* Open session, set field variables
* Constructor - Initialises user from JWT token if available
*
* Checks for a JWT token in the Authorization header and validates it.
* If valid, sets user properties based on the token payload.
*/
public function __construct() {
session_start();
// Initialise default values
$this->_username = "None";
$this->_loggedIn = false;
$this->_userId = "0";
$this->_accessLevel = null;
// if user logged in, set variables.
if(isset($_SESSION['login'])) {
$this->_username = $_SESSION['login'];
$this->_userId = $_SESSION['uid'];
$this->_loggedIn = true;
$this->_accessLevel = $_SESSION['accessLevel'];
$this->_authService = new AuthService();
// Check for JWT token in Authorization header
$headers = getallheaders();
$token = isset($headers['Authorization']) ? str_replace('Bearer ', '', $headers['Authorization']) : null;
// Validate token if it exists
if ($token) {
$payload = $this->_authService->validateToken($token);
if ($payload) {
$this->_username = $payload['username'];
$this->_userId = $payload['uid'];
$this->_accessLevel = $payload['accessLevel'];
$this->_loggedIn = true;
}
}
}
private function setAccessLevel($level) {
$this->_accessLevel = $level;
$_SESSION['accessLevel'] = $level;
}
/**
* Gets the user's access level
*
* @return int|null The user's access level (admin = 1, regular user = 2) or null if not set
*/
public function getAccessLevel() {
return $this->_accessLevel;
}
/**
* @param $username
* @param $password
* @return bool
* Using a username and password, authenticate a user and assign variables from query
* Authenticates a user using username and password
*
* Checks credentials against the database and generates a JWT token if valid.
* Sets user properties if authentication is successful.
*
* @param string $username The username to authenticate
* @param string $password The password to verify
* @return string|bool JWT token if authentication was successful, false otherwise
*/
public function Authenticate($username, $password): bool
public function Authenticate($username, $password)
{
$users = new UserDataSet();
$userDataSet = $users->checkUserCredentials($username, $password);
$accessLevel = $users->checkAccessLevel($username);
if(count($userDataSet) > 0) {
$_SESSION['login'] = $username;
$_SESSION['uid'] = $userDataSet[0]->getId();
$this->setAccessLevel($accessLevel);
$userData = $userDataSet[0];
$accessLevel = $users->checkAccessLevel($username);
// Generate JWT token
$token = $this->_authService->generateToken([
'id' => $userData->getId(),
'username' => $userData->getUsername(),
'userType' => $accessLevel
]);
// Set user properties
$this->_loggedIn = true;
$this->_username = $username;
$this->_userId = $userDataSet[0]->getId();
return true;
$this->_userId = $userData->getId();
$this->_accessLevel = $accessLevel;
return $token;
}
else {
$this->_loggedIn = false;
return false;
}
}
/**
* @return void
* Unset user variables from session, and set variables to default values - destroying session.
* Static method to check if a request is authenticated
*
* This method can be called from any controller to check if the request
* has a valid JWT token. It returns the payload if authenticated or
* sends an error response and returns false if not.
*
* @param bool $required Whether authentication is required (defaults to true)
* @return array|false The payload if authenticated, false otherwise
*/
public function logout() {
unset($_SESSION['login']);
unset($_SESSION['uid']);
$this->_loggedIn = false;
$this->_username = "None";
$this->_userId = "0";
session_destroy();
}
public function isLoggedIn(): bool
public static function checkAuth(bool $required = true)
{
return $this->_loggedIn;
$authService = new AuthService();
// Get the token from the Authorization header
$headers = getallheaders();
$token = isset($headers['Authorization']) ? str_replace('Bearer ', '', $headers['Authorization']) : null;
// Validate the token
$payload = $token ? $authService->validateToken($token) : null;
// If authentication is required and no valid token, return error
if ($required && !$payload) {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['error' => 'Authentication required']);
return false;
}
return $payload;
}
public function __destruct()
/**
* Static method to check if a request is from an admin
*
* This method can be called from any controller to check if the request
* has a valid JWT token with admin access level. It returns the payload
* if authenticated as admin or sends an error response and returns false if not.
*
* @return array|false The payload if authenticated as admin, false otherwise
*/
public static function checkAdmin()
{
$payload = self::checkAuth(true);
if ($payload && isset($payload['accessLevel']) && ($payload['accessLevel'] == 1 || $payload['accessLevel'] == 0)) {
return $payload;
}
header('Content-Type: application/json');
http_response_code(403);
echo json_encode(['error' => 'Admin access required']);
return false;
}
}

0
Models/UserData.php Normal file → Executable file
View File

0
Models/UserDataSet.php Normal file → Executable file
View File

169
Views/index.phtml Normal file → Executable file
View File

@@ -1,60 +1,121 @@
<?php require('template/header.phtml') ?>
<div class="row">
<div class="col-5 me-auto">
<p><?php echo $view->dbMessage; ?></p>
<?php
/**
* Main index view for the EcoBuddy application
*
* This file serves as the main view for the application, displaying
* a table of facilities with various actions depending on the user's
* access level. It includes modals for creating, updating, deleting,
* and viewing statuses of facilities.
*
* The table is populated dynamically using JavaScript, with the data
* stored in sessionStorage.
*/
require('template/header.phtml')
?>
<div class="row">
<div class="col-12 p-0" id="facilityContent">
<!-- Main content -->
<div class="card shadow-sm border-0 rounded-3">
<!-- Title and add button (admins only) -->
<div class="card-header bg-light py-3">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<!-- Search and filter controls -->
<div class="d-flex flex-column flex-lg-row search-controls mx-auto">
<form class="d-flex flex-column flex-lg-row gap-2 w-100" role="search" action="" method="POST">
<div class="input-group flex-grow-1">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-search text-success"></i>
</span>
<input class="form-control border-start-0" id="searchInput" type="search" name="filter" placeholder="Search..." aria-label="Search">
</div>
</form>
</div>
</div>
<!-- Admin-only buttons -->
<div id="adminButtons" style="display: none;">
<!-- Add new facility button (admin only) -->
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#createModal">
<i class="bi bi-plus-circle me-1"></i>Add New Facility
</button>
</div>
</div>
</div>
<!-- Pagination controls -->
<div class="card-footer bg-white py-2">
<?php require('template/pagination.phtml');?>
</div>
<!-- Facilities table -->
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="facilityTable">
<thead class="table-light">
<tr id="tableHeaderRow">
<!-- Table headers will be dynamically populated by JavaScript -->
</tr>
</thead>
<tbody class="border-top-0">
<!-- Table content will be dynamically populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
<form class="col-auto">
<?php require_once('template/createModal.phtml') ?>
</form>
</div>
</div>
<div class="row">
<div class="container-fluid p-3" id="facilityContent">
<table class="table table-bordered">
<thead>
<tr>
<th>Facility ID</th>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Description</th>
<th>Address</th>
<th>Postcode</th>
<th>Lat/Long</th>
<th>Contributor</th>
<?php if($view->user->getAccessLevel() == 1): ?>
<th>Actions</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($view->pageData as $facilityData): ?>
<tr>
<td><?= htmlspecialchars($facilityData->getId() ?? 'N/A') ?></td>
<td><?= htmlspecialchars($facilityData->getTitle() ?? 'N/A') ?></td>
<td><?= htmlspecialchars($facilityData->getCategory() ?? 'N/A') ?></td>
<td><?= htmlspecialchars($facilityData->getStatus() ?? 'N/A') ?></td>
<td><?= htmlspecialchars($facilityData->getDescription() ?? 'N/A') ?></td>
<td><?= htmlspecialchars(trim(($facilityData->getHouseNumber() ?? '') . ' ' .
($facilityData->getStreetName() ?? '') . ' ' .
($facilityData->getCounty() ?? '') . ' ' .
($facilityData->getTown() ?? ''))) ?></td>
<td><?= htmlspecialchars($facilityData->getPostcode() ?? 'N/A') ?></td>
<td><?= htmlspecialchars(($facilityData->getLat() ?? 'N/A') . ', ' .
($facilityData->getLng() ?? 'N/A')) ?></td>
<td><?= htmlspecialchars($facilityData->getContributor() ?? 'N/A') ?></td>
<?php if($view->user->getAccessLevel() == 1): ?>
<td class="btn-group">
<?php require("template/updateModal.phtml") ?>
<?php require("template/deleteModal.phtml") ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Include modal templates -->
<?php require('template/createModal.phtml') ?>
<?php require('template/updateModal.phtml') ?>
<?php require('template/deleteModal.phtml') ?>
<?php require('template/statusModal.phtml') ?>
<!-- Regular user view (no admin buttons) -->
<div id="regularUserView"></div>
<?php require('template/footer.phtml') ?>
<script>
// Function to update UI based on user role
async function updateRoleBasedUI() {
const adminButtons = document.getElementById('adminButtons');
const regularUserView = document.getElementById('regularUserView');
const tableHeaderRow = document.getElementById('tableHeaderRow');
// Validate authentication with server first
let isAdmin = false;
if (auth.isAuthenticated()) {
try {
// This will validate the token with the server and handle refresh if needed
const isValid = await auth.validateOnLoad();
if (isValid) {
isAdmin = auth.isAdmin();
}
} catch (error) {
console.error('Error validating authentication:', error);
isAdmin = false;
}
}
// Show/hide admin buttons
if (adminButtons) {
adminButtons.style.display = isAdmin ? 'block' : 'none';
}
if (regularUserView) {
regularUserView.style.display = isAdmin ? 'none' : 'block';
}
}
// Update UI when the page loads
document.addEventListener('DOMContentLoaded', updateRoleBasedUI);
// Also update when auth state changes
window.addEventListener('storage', function(e) {
if (e.key === 'token' || e.key === 'user') {
updateRoleBasedUI();
}
});
</script>
<?php require('template/footer.phtml');?>

195
Views/map.phtml Normal file
View File

@@ -0,0 +1,195 @@
<?php require_once('template/header.phtml') ?>
<style>
#mapOverlay {
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
opacity: 1;
visibility: visible;
pointer-events: auto;
}
#mapOverlay.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
/* Custom styles for facility popups */
.facility-popup .leaflet-popup-content-wrapper {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.facility-popup .leaflet-popup-content {
margin: 0;
min-width: 250px;
}
.facility-details {
max-height: 300px;
overflow-y: auto;
}
.facility-details::-webkit-scrollbar {
width: 6px;
}
.facility-details::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.facility-details::-webkit-scrollbar-thumb {
background: #198754;
border-radius: 3px;
}
/* Comment form styles */
.comment-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
}
.comment-form textarea {
resize: vertical;
min-height: 60px;
}
/* Facility list container styles */
.facility-list-container {
height: calc(100vh - 400px); /* Adjust based on your layout */
display: flex;
flex-direction: column;
}
.facility-list {
overflow-y: auto;
flex-grow: 1;
margin-bottom: 0;
}
.facility-list::-webkit-scrollbar {
width: 6px;
}
.facility-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.facility-list::-webkit-scrollbar-thumb {
background: #198754;
border-radius: 3px;
}
</style>
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="alert alert-warning">
Notice: Facility locations are currently limited to UK Cities.
</div>
</div>
</div>
</div>
<div class="container-fluid pt-0 py-4">
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-md-4 col-lg-3">
<!-- Postcode and radius controls -->
<div class="mb-4">
<h5 class="mb-3">Location Settings</h5>
<form id="postcodeForm" class="mb-3">
<div class="mb-3">
<label for="postcode" class="form-label">Enter Postcode</label>
<div class="input-group">
<span class="input-group-text bg-light">
<i class="bi bi-geo-alt text-success"></i>
</span>
<input type="text" class="form-control" id="postcode" name="postcode"
placeholder="e.g. M1 5GD" required>
<button class="btn btn-success" type="submit">
<i class="bi bi-crosshair"></i>
</button>
</div>
</div>
<div class="mb-3">
<label for="radius" class="form-label">Search Radius (miles)</label>
<select class="form-select" id="radius" name="radius">
<option value="1">1 mile</option>
<option value="5">5 miles</option>
<option value="10" selected>10 miles</option>
<option value="25">25 miles</option>
</select>
</div>
</form>
<!-- Facility list search and container -->
<div class="facility-list-container">
<div class="input-group mb-3">
<span class="input-group-text bg-light">
<i class="bi bi-search text-success"></i>
</span>
<input type="text" class="form-control" id="facilitySearch"
placeholder="Search facilities...">
</div>
<div id="facilityList" class="list-group list-group-flush facility-list"></div>
</div>
</div>
</div>
<div class="col-md-8 col-lg-9">
<!-- Map container with blur overlay -->
<div class="position-relative" style="height: 700px;">
<div id="map" style="height: 100%; width: 100%; z-index: 1;"></div>
<div id="mapOverlay" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
style="backdrop-filter: blur(8px); z-index: 2; background: rgba(255,255,255,0.5);">
<div class="text-center">
<i class="bi bi-geo-alt text-success" style="font-size: 3rem;"></i>
<h4 class="mt-3">Enter a Postcode</h4>
<p class="text-muted mb-0">Please enter a postcode to view facilities on the map</p>
<p class="text-muted mt-0">or use the search button to find facilities near you</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Leaflet CSS and JS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<!-- Add our map handler -->
<script src="/public/js/mapHandler.js"></script>
<script>
// Add facility search functionality
document.addEventListener('DOMContentLoaded', function() {
const facilitySearch = document.getElementById('facilitySearch');
const facilityList = document.getElementById('facilityList');
if (facilitySearch && facilityList) {
facilitySearch.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const items = facilityList.getElementsByClassName('list-group-item');
Array.from(items).forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
});
</script>
<?php require('template/statusModal.phtml') ?>
<?php require_once('template/footer.phtml') ?>

139
Views/template/createModal.phtml Normal file → Executable file
View File

@@ -1,32 +1,119 @@
<button type="button" class="col btn bg-primary btn-outline-primary text-light" data-bs-toggle="modal" data-bs-target="#createModal">
<span class="bi bi-pen-fill"></span>
</button>
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="updateModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="updateModalLabel">Add Facility</h5>
<!-- Create Facility Modal -->
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="createModalLabel">
<i class="bi bi-plus-circle-fill text-success me-2"></i>Add New Facility
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="<?php echo htmlspecialchars($_SERVER['PHP_SELF']); ?>">
<form class="form-inline" method="post" action="<?php echo $_SERVER['PHP_SELF']; ?> ">
<input name="titlCreate" class="form-control rounded mb-2" placeholder="Title">
<input name="cateCreate" class="form-control rounded mb-2" placeholder="Category">
<input name="descCreate" class="form-control rounded mb-2" placeholder="Description">
<input name="hnumCreate" class="form-control rounded mb-2" placeholder="House Number">
<input name="strtCreate" class="form-control rounded mb-2" placeholder="Street Name">
<input name="cntyCreate" class="form-control rounded mb-2" placeholder="County">
<input name="townCreate" class="form-control rounded mb-2" placeholder="Town">
<input name="postCreate" class="form-control rounded mb-2" placeholder="Postcode">
<input name="contCreate" class="form-control rounded mb-2" placeholder="Contributor">
</form>
<button type="submit" class="btn bg-primary btn-outline-primary text-light" name="createButton">Add</button>
<div class="modal-body p-4">
<!-- Create facility form -->
<form id="createForm">
<!-- Form fields -->
<div class="mb-3">
<label for="createTitle" class="form-label">Facility Name</label>
<input type="text" class="form-control" id="createTitle" name="title" required>
</div>
<div class="mb-3">
<label for="createCategory" class="form-label">Category</label>
<select class="form-select" id="createCategory" name="category" required>
<option value="" selected disabled>Select a category</option>
<option value="recycling">Recycling Center</option>
<option value="compost">Composting Facility</option>
<option value="ewaste">E-Waste Collection</option>
<option value="donation">Donation Center</option>
<option value="refill">Refill Station</option>
<option value="repair">Repair Shop</option>
<option value="garden">Community Garden</option>
<option value="market">Farmers Market</option>
<option value="other">Other</option>
</select>
</div>
<div class="mb-3">
<label for="createDescription" class="form-label">Description</label>
<textarea class="form-control" id="createDescription" name="description" rows="3" required></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="createLatitude" class="form-label">Latitude</label>
<input type="number" step="any" class="form-control" id="createLatitude" name="lat" required>
</div>
<div class="col-md-6 mb-3">
<label for="createLongitude" class="form-label">Longitude</label>
<input type="number" step="any" class="form-control" id="createLongitude" name="lng" required>
</div>
</div>
<div class="mb-3">
<label for="createHouseNumber" class="form-label">House Number/Name</label>
<input type="text" class="form-control" id="createHouseNumber" name="houseNumber" required>
</div>
<div class="mb-3">
<label for="createStreetName" class="form-label">Street Name</label>
<input type="text" class="form-control" id="createStreetName" name="streetName" required>
</div>
<div class="mb-3">
<label for="createTown" class="form-label">Town/City</label>
<input type="text" class="form-control" id="createTown" name="town" required>
</div>
<div class="mb-3">
<label for="createCounty" class="form-label">County</label>
<input type="text" class="form-control" id="createCounty" name="county" required>
</div>
<div class="mb-3">
<label for="createPostcode" class="form-label">Postcode</label>
<input type="text" class="form-control" id="createPostcode" name="postcode" required>
</div>
<div id="createError" class="alert alert-danger" style="display: none;"></div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-plus-circle me-1"></i>Create Facility
</button>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Only allow admin users to access this modal
document.addEventListener('DOMContentLoaded', function() {
const createModal = document.getElementById('createModal');
if (createModal) {
createModal.addEventListener('show.bs.modal', async function(event) {
// Validate authentication with server first
let isAdmin = false;
if (auth.isAuthenticated()) {
try {
// This will validate the token with the server and handle refresh if needed
const isValid = await auth.validateOnLoad();
if (isValid) {
isAdmin = auth.isAdmin();
}
} catch (error) {
console.error('Error validating authentication:', error);
isAdmin = false;
}
}
if (!isAdmin) {
event.preventDefault();
alert('You need administrator privileges to add new facilities.');
}
});
}
});
</script>

41
Views/template/deleteModal.phtml Normal file → Executable file
View File

@@ -1,20 +1,37 @@
<button type="button" class="col btn bg-danger btn-outline-danger text-light" data-bs-toggle="modal" data-bs-target="#deleteModal">
<span class="bi bi-trash-fill">
</button>
<!-- Delete Facility Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Delete Facility Record</h5>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-trash text-danger me-2"></i>Delete Facility
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="">
<button type="submit" value="delete" class="btn bg-danger btn-outline-danger text-light" name="deleteButton"">Yes</button>
<input type="hidden" name="id" value="<?= $facilityData->getId()?>">
<button type="button" class="btn btn-outline primary btn-primary" data-bs-dismiss="modal">No</button>
<div class="modal-body p-4">
<form id="deleteForm">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="idDelete" value="">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<span>Are you sure you want to delete this facility record? This action cannot be undone.</span>
</div>
<div class="mt-3">
<p class="mb-1 fw-bold">Facility to be deleted:</p>
<p id="deleteConfirmationText" class="text-danger mb-0"></p>
</div>
</form>
</div>
<div class="modal-footer bg-light">
<div class="w-100 d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="deleteForm" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Delete Permanently
</button>
</div>
</div>
</div>
</div>
</div>

200
Views/template/footer.phtml Normal file → Executable file
View File

@@ -1,8 +1,6 @@
</div>
<div class="site-footer fixed-bottom mt-auto">
<div class="col-auto">
<?php require_once('pagination.phtml'); ?>
</div>
<div class="site-footer mt-auto">
<!-- Footer Content -->
<div class="row">
<div id="footer" class="col-xs-12">
<p class="m-0">George Wilkinson @2024</p>
@@ -10,10 +8,200 @@
</div>
</div>
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<!-- script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script -->
<script src="/js/bootstrap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Application JavaScript -->
<!-- Note: auth.js is already included in the header -->
<!-- Note: facilityData.js is already included in the header -->
<script src="/public/js/comments.js"></script>
<!-- initialise components -->
<script>
// Only run initialization if not already done
if (!window.initializationComplete) {
document.addEventListener('DOMContentLoaded', function() {
// initialise auth service
const loginButton = document.querySelector('[data-bs-toggle="modal"]');
const loginModal = document.getElementById('loginModal');
// initialise all modals
try {
const modalElements = document.querySelectorAll('.modal');
modalElements.forEach(modalElement => {
if (modalElement) {
const modalInstance = new bootstrap.Modal(modalElement, {
backdrop: true,
keyboard: true,
focus: true
});
// Add click handler for modal triggers
const triggers = document.querySelectorAll(`[data-bs-target="#${modalElement.id}"]`);
triggers.forEach(trigger => {
trigger.addEventListener('click', (e) => {
e.preventDefault();
modalInstance.show();
});
});
}
});
} catch (error) {
console.error('Error initializing modals:', error);
}
// initialise CommentsManager
CommentsManager.state.isDomReady = true;
if (window.auth) {
CommentsManager.state.isAuthReady = true;
CommentsManager.checkinitialise();
} else {
window.addEventListener('authReady', () => {
console.log('auth is now ready');
CommentsManager.state.isAuthReady = true;
CommentsManager.checkinitialise();
});
}
// initialise auth form handlers
const loginForm = document.querySelector('#loginModal form');
const loginError = document.querySelector('#loginError');
const captchaContainer = document.querySelector('.captcha-container');
if (loginForm) {
// Show CAPTCHA if needed
if (auth.needsCaptcha() && captchaContainer) {
captchaContainer.style.display = 'flex';
}
// Add login form handler
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
// Get form data
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const captchaInput = document.getElementById('captchaInput')?.value;
// Clear previous error
if (loginError) {
loginError.style.display = 'none';
loginError.textContent = '';
}
// Show loading spinner in submit button
const submitButton = this.querySelector('button[type="submit"]');
const originalButtonContent = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Logging in...</span>
`;
// Record start time for minimum spinner display
const startTime = Date.now();
try {
// Attempt login
const result = await auth.login({
username: username,
password: password,
captchaInput: captchaInput
});
// Calculate elapsed time and wait if needed to show spinner for at least 500ms
const elapsedTime = Date.now() - startTime;
const minSpinnerTime = 500; // 500ms minimum spinner display time
if (elapsedTime < minSpinnerTime) {
await new Promise(resolve => setTimeout(resolve, minSpinnerTime - elapsedTime));
}
if (result.success) {
// Show success message in button
submitButton.classList.remove('btn-success');
submitButton.classList.add('btn-success');
submitButton.innerHTML = `
<i class="bi bi-check-circle me-2"></i>Login successful
`;
// Wait a moment before closing modal and reloading
setTimeout(() => {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('loginModal'));
if (modal) {
modal.hide();
}
// Reload page to update UI
window.location.reload();
}, 500);
} else {
// Show error
if (loginError) {
loginError.textContent = result.error || 'Login failed';
loginError.style.display = 'block';
}
// Show CAPTCHA if needed
if (auth.needsCaptcha() && captchaContainer) {
captchaContainer.style.display = 'flex';
// Generate new CAPTCHA if needed
if (result.captcha) {
document.getElementById('captchaCode').value = result.captcha;
}
}
// Reset button
submitButton.disabled = false;
submitButton.innerHTML = originalButtonContent;
}
} catch (error) {
console.error('Login error:', error);
if (loginError) {
loginError.textContent = error.message || 'An error occurred during login';
loginError.style.display = 'block';
}
// Reset button
submitButton.disabled = false;
submitButton.innerHTML = originalButtonContent;
}
});
}
// Handle logout button
const logoutButton = document.querySelector('button[name="logoutButton"]');
if (logoutButton) {
logoutButton.addEventListener('click', async (e) => {
e.preventDefault();
await auth.logout();
});
}
// Validate token if authenticated
if (auth.isAuthenticated()) {
auth.validateToken().then(valid => {
if (!valid) {
if (!localStorage.getItem('validationAttempted')) {
localStorage.setItem('validationAttempted', 'true');
window.location.reload();
} else {
localStorage.removeItem('validationAttempted');
}
} else {
localStorage.removeItem('validationAttempted');
}
});
}
// Mark initialization as complete
window.initializationComplete = true;
});
}
</script>
</body>
</html>

518
Views/template/header.phtml Normal file → Executable file
View File

@@ -4,100 +4,462 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="description" content="EcoBuddy - Sustainable facilities management platform">
<meta name="author" content="">
<link rel="icon" type="image/x-icon" href="/images/ecoBuddy_x32.png"
<!-- Bootstrap core CSS -->
<link href="/css/bootstrap.css" rel="stylesheet">
<!-- Bootstrap theme -->
<link href="/css/bootstrap-theme.css" rel="stylesheet">
<link href="/css/my-style.css" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="/images/ecoBuddy_x32.png">
<!-- Bootstrap core CSS from CDN for faster loading -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- CSS theme -->
<link href="/public/css/default.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="/css/bootstrap-icons.css" rel="stylesheet">
<link href="/public/css/bootstrap-icons.css" rel="stylesheet">
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Dynamic page title based on the current page -->
<title>Ecobuddy - <?php echo $view->pageTitle; ?></title>
<!-- Load simplified authentication helper -->
<script src="/public/js/auth.js"></script>
<!-- Load API client -->
<script src="/public/js/apiClient.js"></script>
<!-- Load facility data script -->
<script src="/public/js/facilityData.js"></script>
<!-- Initialise facility data from PHP server-side data -->
<script>
<?php if (isset($view->facilityDataSet) && is_array($view->facilityDataSet)): ?>
try {
// Convert PHP data to JavaScript object with proper encoding
// Using JSON_UNESCAPED_SLASHES and JSON_UNESCAPED_UNICODE for proper character handling
const initialData = <?php echo json_encode($view->facilityDataSet,
JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE |
JSON_PARTIAL_OUTPUT_ON_ERROR
); ?>;
// Validate and store data in sessionStorage for use across the application
if (Array.isArray(initialData) && initialData.length > 0) {
sessionStorage.setItem('facilityData', JSON.stringify(initialData));
// initialise based on DOM state to ensure scripts run at the right time
if (document.readyState === 'complete' || document.readyState === 'interactive') {
if (typeof initialiseFacilityData === 'function') {
initialiseFacilityData(initialData);
}
} else {
document.addEventListener('DOMContentLoaded', function() {
if (typeof initialiseFacilityData === 'function') {
initialiseFacilityData(initialData);
}
});
}
}
// Add client-side authentication check to update UI
document.addEventListener('DOMContentLoaded', function() {
// Check if user is authenticated on the client side
if (auth && auth.isAuthenticated()) {
console.log('User is authenticated on client side');
// Get user data
const user = auth.getUser();
if (user) {
console.log('User data:', user);
// Hide login button if it exists
const loginButton = document.getElementById('loginButton');
if (loginButton) {
loginButton.style.display = 'none';
}
// Hide login modal if it exists
const loginModal = document.getElementById('loginModal');
if (loginModal) {
loginModal.style.display = 'none';
}
// Show user menu
const userMenuContainer = document.createElement('div');
userMenuContainer.className = 'user-menu';
userMenuContainer.innerHTML = `
<div class="user-avatar">
<i class="bi bi-person-fill text-success"></i>
</div>
<div class="dropdown">
<button class="btn btn-light dropdown-toggle" type="button" id="userMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
${user.username}
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userMenuButton">
<li><a class="dropdown-item" href="#"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><a class="dropdown-item" href="#"><i class="bi bi-gear me-2"></i>Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><button class="dropdown-item text-danger" id="logoutButton"><i class="bi bi-box-arrow-right me-2"></i>Logout</button></li>
</ul>
</div>
`;
// Replace login button with user menu
if (loginButton) {
loginButton.parentNode.replaceChild(userMenuContainer, loginButton);
}
// Add logout button handler
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.addEventListener('click', async function() {
await auth.logout();
window.location.reload();
});
}
}
}
});
} catch (error) {
console.error('Error processing facility data:', error);
}
<?php endif; ?>
</script>
</head>
<nav class="navbar navbar-expand-lg p-0 m-2 border rounded-2">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarTogglerDemo03" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation">
<body role="document">
<!-- Navigation bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm sticky-top">
<div class="container-fluid px-3">
<!-- Brand logo and name -->
<a class="navbar-brand d-flex align-items-center" href="/index.php">
<img src="/images/ecoBuddy_x64.png" alt="EcoBuddy Logo" width="48" height="48" class="me-2">
<span class="fw-bold text-success">EcoBuddy</span>
</a>
<!-- Mobile menu toggle -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
aria-controls="navbarContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/index.php"><img id="navIcon" class="img-thumbnail bg-transparent border-3 border-success border-opacity-25 rounded my-1 me-2" height="64px" width="64px" src="/images/ecoBuddy_x64.png" alt=""/><span class="pt-5 mb-auto">Ecobuddy</span></a>
<div class="collapse navbar-collapse" id="navbarTogglerDemo03">
<!-- Navigation content -->
<div class="collapse navbar-collapse" id="navbarContent">
<!-- Main navigation links -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/index.php">
<i class="bi bi-house-fill me-1"></i>Home
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/map.php">
<i class="bi bi-map-fill me-1"></i>Map
</a>
</li>
</ul>
<form class="row m-0 me-2 align-content-center align-content-center align-items-center" role="search" action="" method="POST">
<div class="col">
<div class="form-floating input-group">
<select name="sort" class="form-select border-3 border-success-subtle" id="sort">
<option value="1" <?php if(isset($_GET['sort']) && $_GET['sort'] == '1') echo 'selected'; ?>>Title</option>
<option value="2" <?php if(isset($_GET['sort']) && $_GET['sort'] == '2') echo 'selected'; ?>>Category</option>
<option value="0" <?php if(isset($_GET['sort']) && $_GET['sort'] == '0') echo 'selected'; ?>>Status</option>
<option value="3" <?php if(isset($_GET['sort']) && $_GET['sort'] == '3') echo 'selected'; ?>>Description</option>
<option value="4" <?php if(isset($_GET['sort']) && $_GET['sort'] == '4') echo 'selected'; ?>>Street Name</option>
<option value="5" <?php if(isset($_GET['sort']) && $_GET['sort'] == '5') echo 'selected'; ?>>County</option>
<option value="6" <?php if(isset($_GET['sort']) && $_GET['sort'] == '6') echo 'selected'; ?>>Town</option>
<option value="7" <?php if(isset($_GET['sort']) && $_GET['sort'] == '7') echo 'selected'; ?>>Postcode</option>
<option value="8" <?php if(isset($_GET['sort']) && $_GET['sort'] == '8') echo 'selected'; ?>>Contributor</option>
</select>
<span class="form-floating input-group">
<select class="form-select border-3 border-start-0 rounded-end border-success-subtle" name="dir" id="dir">
<option value="asc" <?php if($_GET['dir'] == 'asc') echo 'selected'; ?>>Asc</option>
<option value="desc" <?php if($_GET['dir'] == 'desc') echo 'selected'; ?>>Desc</option>
</select>
<label for="dir">Order</label>
</span>
<label for="sort">Sort By</label>
</div>
</div>
<div class="col">
<div class="form-floating input-group">
<select name="filterCat" class="form-select border-3 border-success-subtle" id="filterCat">
<option value="1" <?php if(isset($_GET['category']) && $_GET['category'] == '1') echo 'selected'; ?>>Title</option>
<option value="2" <?php if(isset($_GET['category']) && $_GET['category'] == '2') echo 'selected'; ?>>Category</option>
<option value="0" <?php if(isset($_GET['category']) && $_GET['category'] == '0') echo 'selected'; ?>>Status</option>
<option value="3" <?php if(isset($_GET['category']) && $_GET['category'] == '3') echo 'selected'; ?>>Description</option>
<option value="4" <?php if(isset($_GET['category']) && $_GET['category'] == '4') echo 'selected'; ?>>Street Name</option>
<option value="5" <?php if(isset($_GET['category']) && $_GET['category'] == '5') echo 'selected'; ?>>County</option>
<option value="6" <?php if(isset($_GET['category']) && $_GET['category'] == '6') echo 'selected'; ?>>Town</option>
<option value="7" <?php if(isset($_GET['category']) && $_GET['category'] == '7') echo 'selected'; ?>>Postcode</option>
<option value="8" <?php if(isset($_GET['category']) && $_GET['category'] == '8') echo 'selected'; ?>>Contributor</option>
</select>
<span class="input-group-text bi bi-filter-circle bg-success-subtle border-0 rounded-end" id="filterCat"></span>
<label for="filterCat">Column Filter</label>
</div>
</div>
<div class="col">
<div class="form-floating input-group">
<label for="search"></label>
<input placeholder="<?php if(isset($_GET['filter'])) echo $_GET['filter']; ?>" class="form-control border-3 border-success-subtle" id="search" type="search" name="filter" aria-label="Search">
<span class="input-group-text bg-success-subtle border-0 rounded-end" id="search">
<button class="btn bg-light bg-success-subtle" type="submit"><span class="bi bi-search"></span></button>
</span>
</div>
</div>
</form>
<div class="me-2 ms-2">
<div class="col-sm" id="loginStatus">
<?php
if(!$view->user->isLoggedIn()) {
require_once('Views/template/loginModal.phtml');
}
if($view->user->isLoggedIn()) {
require_once('Views/template/logoutButton.phtml');
}
?>
<!-- User account section -->
<div class="ms-lg-3 mt-3 mt-lg-0" id="userAuthSection">
<!-- This section will be populated by JavaScript based on authentication status -->
<div class="spinner-border spinner-border-sm text-success" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script>
// Function to update the authentication UI
async function updateAuthUI() {
const authSection = document.getElementById('userAuthSection');
// Show loading spinner (this is unnecessary but I like it :D)
authSection.innerHTML = `
<div class="spinner-border spinner-border-sm text-success" role="status">
<span class="visually-hidden">Loading...</span>
</div>
`;
// Record start time for minimum spinner display
const startTime = Date.now();
// Validate token with server first
let isAuthenticated = false;
if (auth.isAuthenticated()) {
try {
// This will validate the token with the server and handle refresh if needed
isAuthenticated = await auth.validateOnLoad();
} catch (error) {
console.error('Error validating authentication:', error);
isAuthenticated = false;
}
}
// Calculate elapsed time and wait if needed to show my very cool spinner for 500ms
const elapsedTime = Date.now() - startTime;
const minSpinnerTime = 500; // 500ms
if (elapsedTime < minSpinnerTime) {
await new Promise(resolve => setTimeout(resolve, minSpinnerTime - elapsedTime));
}
if (isAuthenticated) {
// User is logged in - show user menu
const user = auth.getUser();
const isAdmin = auth.isAdmin();
authSection.innerHTML = `
<div class="user-menu">
<div class="user-avatar">
<i class="bi bi-person-fill text-success"></i>
</div>
<div class="dropdown">
<button class="btn btn-link dropdown-toggle text-dark text-decoration-none" type="button" id="userMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
${user.username}
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userMenuDropdown">
${isAdmin ? '<li><a class="dropdown-item" href="/admin"><i class="bi bi-gear me-2"></i>Admin Panel</a></li>' : ''}
<li><a class="dropdown-item" href="/profile"><i class="bi bi-person me-2"></i>My Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li><button class="dropdown-item" name="logoutButton"><i class="bi bi-box-arrow-right me-2"></i>Logout</button></li>
</ul>
</div>
</div>
`;
// Add event listener for logout button
const logoutButton = authSection.querySelector('button[name="logoutButton"]');
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
await auth.logout();
window.location.reload();
});
}
} else {
// User is not logged in - show login button
authSection.innerHTML = `
<button type="button" class="btn btn-success" id="loginButton" data-bs-toggle="modal" data-bs-target="#loginModal">
<i class="bi bi-box-arrow-in-right me-1"></i>Login
</button>
`;
}
}
// Update auth UI when the page loads
document.addEventListener('DOMContentLoaded', updateAuthUI);
// Also update when auth state changes
window.addEventListener('storage', function(e) {
if (e.key === 'token' || e.key === 'user') {
updateAuthUI();
}
});
</script>
</div>
</div>
</nav>
<body role="document">
<div class="main container-fluid">
<div class="col" id="content">
<!-- Login Modal -->
<div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="loginModalLabel">
<i class="bi bi-box-arrow-in-right text-success me-2"></i>Login to EcoBuddy
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-person text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="username" name="username" placeholder="Enter your username" required>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-lock text-success"></i>
</span>
<input type="password" class="form-control border-start-0" id="password" name="password" placeholder="Enter your password" required>
</div>
</div>
<div id="loginError" class="alert alert-danger" style="display: none;"></div>
<!-- CAPTCHA container (hidden by default) -->
<div class="captcha-container mb-3" style="display: none;">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Security Check</h6>
<p class="card-text small">Please enter the characters you see below:</p>
<div class="d-flex align-items-center mb-2">
<div class="captcha-code bg-white p-2 border rounded me-2 text-center" style="font-family: monospace; letter-spacing: 3px; font-weight: bold; min-width: 100px;">
<span id="captchaDisplay"></span>
</div>
<input type="text" class="form-control" id="captchaInput" placeholder="Enter code" autocomplete="off">
<input type="hidden" id="captchaCode" name="captchaCode">
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshCaptcha">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success">
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</button>
</div>
</form>
</div>
<div class="modal-footer bg-light">
<p class="small text-muted mb-0">Don't have an account? <a href="#" onclick="alert('Please contact the administrator to create an account.');" class="text-success">Register</a></p>
</div>
</div>
</div>
</div>
<script>
// initialise login modal functionality
document.addEventListener('DOMContentLoaded', function() {
const loginModal = document.getElementById('loginModal');
const loginForm = document.getElementById('loginForm');
const loginError = document.getElementById('loginError');
const captchaContainer = document.querySelector('.captcha-container');
const captchaDisplay = document.getElementById('captchaDisplay');
const refreshCaptchaBtn = document.getElementById('refreshCaptcha');
// Function to update CAPTCHA display
async function updateCaptcha() {
try {
const captcha = await auth.generateCaptcha();
if (captchaDisplay) {
captchaDisplay.textContent = captcha;
}
if (document.getElementById('captchaCode')) {
document.getElementById('captchaCode').value = captcha;
}
} catch (error) {
console.error('Error updating CAPTCHA:', error);
if (loginError) {
loginError.textContent = 'Error generating security check. Please try again.';
loginError.style.display = 'block';
}
}
}
// Handle CAPTCHA refresh button
if (refreshCaptchaBtn) {
refreshCaptchaBtn.addEventListener('click', updateCaptcha);
}
// Show/hide CAPTCHA based on login attempts
if (auth.needsCaptcha() && captchaContainer) {
captchaContainer.style.display = 'block';
updateCaptcha();
}
// Handle form submission
if (loginForm) {
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
// Get form data
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const captchaInput = document.getElementById('captchaInput')?.value;
// Show loading state
const submitButton = this.querySelector('button[type="submit"]');
const originalButtonContent = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Logging in...</span>
`;
try {
// Attempt login
const result = await auth.login({
username,
password,
captchaInput
});
if (result.success) {
// Show success message
submitButton.classList.remove('btn-success');
submitButton.classList.add('btn-success');
submitButton.innerHTML = `
<i class="bi bi-check-circle me-2"></i>Login successful
`;
// Hide error message if shown
if (loginError) {
loginError.style.display = 'none';
}
// Close modal and reload page after a brief delay
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(loginModal);
if (modal) {
modal.hide();
}
window.location.reload();
}, 500);
} else {
// Show error message
if (loginError) {
loginError.textContent = result.error;
loginError.style.display = 'block';
}
// Show CAPTCHA if needed
if (result.captcha && captchaContainer) {
captchaContainer.style.display = 'block';
updateCaptcha();
}
// Reset button
submitButton.disabled = false;
submitButton.innerHTML = originalButtonContent;
}
} catch (error) {
// Show error message
if (loginError) {
loginError.textContent = error.message || 'An error occurred during login';
loginError.style.display = 'block';
}
// Reset button
submitButton.disabled = false;
submitButton.innerHTML = originalButtonContent;
}
});
}
// Hide modal if user is already authenticated
if (auth.isAuthenticated() && loginModal) {
const modalInstance = bootstrap.Modal.getInstance(loginModal);
if (modalInstance) {
modalInstance.hide();
}
}
});
</script>
<!-- Main content container -->
<div class="container-fluid py-4 px-3">
<div class="row" id="content">

View File

@@ -1,18 +0,0 @@
<span class="ms-5 me-5 row alert alert-danger" role="alert"><?= $view->loginError ?></span>
<div class="row captcha-container">
<!-- CAPTCHA Display -->
<div class="form-floating mb-3 col">
<input type="text" class="form-control" id="captchaCode" value="<?php
// Generate a simple 5-character CAPTCHA
$captcha = substr(str_shuffle("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), 0, 5);
echo $captcha;
?>" readonly>
<label for="captchaCode">CAPTCHA Code</label>
</div>
<!-- CAPTCHA Input -->
<div class="form-floating mb-3 col">
<input type="text" class="form-control" id="captchaInput" name="captchaInput" placeholder="Enter CAPTCHA" required>
<label for="captchaInput">Enter CAPTCHA</label>
</div>
</div>

153
Views/template/loginModal.phtml Normal file → Executable file
View File

@@ -1,39 +1,140 @@
<button type="button" class="btn bg-primary btn-outline-primary text-light m-auto" data-bs-toggle="modal"
data-bs-target="#loginModal">
Login
</button>
<?= isset($view->loginError) ? '<div class="modal-backdrop fade show"></div>' : '' ?>
<div class="modal fade <?= isset($view->loginError) ? 'show' : '' ?>" id="loginModal" tabindex="-1"
aria-labelledby="loginModalLabel" aria-hidden="<?= isset($view->loginError) ? 'false' : 'true' ?>"
style="<?= isset($view->loginError) ? 'display: block;' : '' ?>">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginModalLabel">Login</h5>
<!-- Login Modal -->
<div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="loginModalLabel">
<i class="bi bi-box-arrow-in-right text-success me-2"></i>Login to EcoBuddy
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="<?php echo htmlspecialchars($_SERVER['PHP_SELF']); ?>">
<div class="modal-body p-4">
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Username"
required>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-person text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="username" name="username" placeholder="Enter your username" required>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Password"
required>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-lock text-success"></i>
</span>
<input type="password" class="form-control border-start-0" id="password" name="password" placeholder="Enter your password" required>
</div>
</div>
<div id="loginError" class="alert alert-danger" style="display: none;"></div>
<!-- CAPTCHA container (hidden by default) -->
<div class="captcha-container mb-3" style="display: none;">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Security Check</h6>
<p class="card-text small">Please enter the characters you see below:</p>
<div class="d-flex align-items-center mb-2">
<div class="captcha-code bg-white p-2 border rounded me-2 text-center" style="font-family: monospace; letter-spacing: 3px; font-weight: bold; min-width: 100px;">
<span id="captchaDisplay"></span>
</div>
<input type="text" class="form-control" id="captchaInput" placeholder="Enter code" autocomplete="off">
<input type="hidden" id="captchaCode" name="captchaCode">
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshCaptcha">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success">
<i class="bi bi-box-arrow-in-right me-2"></i>Login
</button>
</div>
<?php if (isset($view->loginError)) { include('Views/template/loginError.phtml');} ?>
<button type="submit" class="btn bg-primary btn-outline-primary text-light" name="loginButton">Login
</button>
</form>
</div>
<div class="modal-footer">
<a href="/index.php <?php unset($_GET['modal'])?>" type="button" class="btn btn-warning btn-outline-warning text-light" data-bs-dismiss="modal">
Close
</a>
<div class="modal-footer bg-light">
<div class="w-100 d-flex justify-content-between align-items-center">
<small class="text-muted">Don't have an account? <a href="" onclick="alert('Please contact the administrator to create an account.');" class="text-success">Register</a></small>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Handle CAPTCHA functionality
document.addEventListener('DOMContentLoaded', function() {
const captchaContainer = document.querySelector('.captcha-container');
const captchaDisplay = document.getElementById('captchaDisplay');
const refreshCaptchaBtn = document.getElementById('refreshCaptcha');
const loginForm = document.getElementById('loginForm');
// Function to update CAPTCHA display
async function updateCaptcha() {
try {
const captcha = await auth.generateCaptcha();
captchaDisplay.textContent = captcha;
document.getElementById('captchaCode').value = captcha;
} catch (error) {
console.error('Error updating CAPTCHA:', error);
}
}
// Handle CAPTCHA refresh button
if (refreshCaptchaBtn) {
refreshCaptchaBtn.addEventListener('click', updateCaptcha);
}
// Show/hide CAPTCHA based on login attempts
if (auth.needsCaptcha()) {
captchaContainer.style.display = 'block';
updateCaptcha();
}
// Handle form submission
if (loginForm) {
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const captchaInput = document.getElementById('captchaInput')?.value;
const result = await auth.login({
username,
password,
captchaInput
});
if (result.success) {
// Close modal and reload page
const modal = bootstrap.Modal.getInstance(document.getElementById('loginModal'));
if (modal) {
modal.hide();
}
window.location.reload();
} else {
// Show error
const loginError = document.getElementById('loginError');
if (loginError) {
loginError.textContent = result.error;
loginError.style.display = 'block';
}
// Show CAPTCHA if needed
if (result.captcha) {
captchaContainer.style.display = 'block';
updateCaptcha();
}
}
});
}
});
</script>

View File

@@ -1,4 +0,0 @@
<form class="form-floating my-auto" method="post" action="<?php echo $_SERVER['PHP_SELF']; ?> ">
<?php echo "<p class='text-center bg-light border-0 rounded mb-1' style='color: black;'>" . $user->getUsername() . "<span class='bi bi-person-fill'></span></p>"?>
<button class="btn bg-danger btn-outline-danger text-light" type="submit" name="logoutButton">Logout</button>
</form>

110
Views/template/pagination.phtml Normal file → Executable file
View File

@@ -1,37 +1,77 @@
<div>
<div class="row mb-2">
<!-- Form for Pagination -->
<div id="paginationButtons" class="col-auto m-auto btn-group">
<?php
$param = $_GET;
unset($param['page']); // Remove the page parameter to avoid duping
function buildUrl($page, $param): string
{
$param['page'] = $page;
return '?' . http_build_query($param);
}
?>
<!-- Start Button -->
<a class="btn btn-outline-primary" href="<?= buildUrl(0, $param) ?>0" <?= $view->pageNumber <= 0 ? 'disabled' : '' ?>><i class="bi bi-chevron-double-left"></i> Start</a>
<!-- Back Button -->
<a class="btn btn-outline-primary" href="<?= buildUrl(max($view->pageNumber - 1, 0), $param)?> " <?= $view->pageNumber <= 0 ? 'disabled' : '' ?>><i class="bi bi-chevron-left"></i> Back</a>
<!-- Dynamic Page Buttons -->
<?php
$totalPages = $view->paginator->getTotalPages();
for ($i = $view->pageNumber - 2; $i <= $view->pageNumber + 2; $i++) {
if ($i >= 0 && $i < $totalPages): ?>
<a href="<?= buildUrl($i, $param) ?>"
class="btn <?= $i === $view->pageNumber ? 'btn-dark' : 'btn-outline-primary' ?>"
<?= $i === $view->pageNumber ? 'disabled' : '' ?>>
<?= $i + 1 ?>
</a>
<?php endif;
} ?>
<!-- Forward Button -->
<a class="btn btn-outline-primary" href="<?=buildUrl(min($view->pageNumber + 1, $totalPages), $param)?>" <?= $view->pageNumber >= $totalPages - 1 ? 'disabled' : '' ?>>Forward <i class="bi bi-chevron-right"></i></a>
<!-- End Button -->
<a class="btn btn-outline-primary" href="<?= buildUrl($totalPages - 1, $param) ?>"<?= $view->pageNumber >= $totalPages - 1 ? 'disabled' : '' ?>>End <i class="bi bi-chevron-double-right"></i></a>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-center gap-3">
<div class="text-muted small">
<span id="paginationInfo" class="d-flex align-items-center">
<i class="bi bi-info-circle me-2 text-success"></i>
<span>Showing facilities</span>
</span>
</div>
<!-- Pagination controls -->
<nav class="bg-transparent" aria-label="Facility table pagination">
<ul class="pagination pagination-sm mb-0 border-2 rounded border-success" id="paginationControls">
<!-- First page button -->
<li class="page-item">
<a class="page-link border-0 text-success" href="#" aria-label="First" id="firstPage">
<i class="bi bi-chevron-double-left"></i>
</a>
</li>
<!-- Previous page button -->
<li class="page-item">
<a class="page-link border-0 text-success" href="#" aria-label="Previous" id="prevPage">
<i class="bi bi-chevron-left"></i>
</a>
</li>
<!-- Dynamic page numbers will be inserted here as list items -->
<!-- Next page button -->
<li class="page-item">
<a class="page-link border-0 text-success" href="#" aria-label="Next" id="nextPage">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<!-- Last page button -->
<li class="page-item">
<a class="page-link border-0 text-success" href="#" aria-label="Last" id="lastPage">
<i class="bi bi-chevron-double-right"></i>
</a>
</li>
</ul>
</nav>
<!-- Items per page selector -->
<div class="d-flex align-items-center">
<label for="itemsPerPage" class="form-label text-muted small mb-0 me-2">Items per page:</label>
<select class="form-select form-select-sm" id="itemsPerPage" style="width: 70px;">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Set up items per page selector
const itemsPerPageSelect = document.getElementById('itemsPerPage');
if (itemsPerPageSelect) {
itemsPerPageSelect.addEventListener('change', function() {
// Update items per page in the pagination system
if (typeof itemsPerPage !== 'undefined') {
itemsPerPage = parseInt(this.value);
currentPage = 1; // Reset to first page
// Recalculate total pages
if (typeof filteredData !== 'undefined' && typeof totalPages !== 'undefined') {
totalPages = Math.ceil(filteredData.length / itemsPerPage);
// Update table with new pagination
if (typeof updateTableWithPagination === 'function') {
updateTableWithPagination();
}
}
}
});
}
});
</script>

View File

@@ -0,0 +1,64 @@
<!-- Facility Comments Modal -->
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="statusModalLabel">
<i class="bi bi-chat-square-text text-primary me-2"></i>Facility Comments
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<!-- Comments container - this will be populated by JavaScript -->
<div id="commentsContainer" class="comments-container">
<!-- Comments will be loaded here dynamically -->
<div class="text-center py-4 text-muted" id="noCommentsMessage">
<i class="bi bi-chat-square-text fs-4 d-block mb-2"></i>
<p>Loading comments...</p>
</div>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Edit Comment Modal -->
<div class="modal fade" id="editCommentModal" tabindex="-1" aria-labelledby="editCommentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="editCommentModalLabel">
<i class="bi bi-pencil-square text-primary me-2"></i>Edit Comment
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<form id="editCommentForm">
<input type="hidden" name="action" value="editComment">
<input type="hidden" name="commentId" id="editCommentId" value="">
<div class="mb-3">
<label for="editCommentText" class="form-label">Edit Your Comment</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-pencil text-primary"></i>
</span>
<textarea class="form-control border-start-0" id="editCommentText" name="editCommentText" rows="4" placeholder="Update your comment..." required></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer bg-light">
<div class="w-100 d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="editCommentForm" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</div>
</div>
</div>
</div>
</div>

158
Views/template/updateModal.phtml Normal file → Executable file
View File

@@ -1,31 +1,143 @@
<button type="button" class="col btn bg-primary btn-outline-primary text-light" data-bs-toggle="modal" data-bs-target="#updateModal">
<span class="bi bi-pen-fill"></span>
</button>
<!-- Update Facility Modal -->
<div class="modal fade" id="updateModal" tabindex="-1" aria-labelledby="updateModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="updateModalLabel">Update Facility</h5>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title" id="updateModalLabel">
<i class="bi bi-pencil-square text-success me-2"></i>Update Facility
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="<?php echo htmlspecialchars($_SERVER['PHP_SELF']); ?>">
<input name="titlUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getTitle() ?? '' ?>" placeholder="Title">
<input name="cateUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getCategory() ?? '' ?>" placeholder="Category">
<input name="descUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getDescription() ?? '' ?>" placeholder="Description">
<input name="hnumUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getHouseNumber() ?? '' ?>" placeholder="House Number">
<input name="strtUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getStreetName() ?? '' ?>" placeholder="Street Name">
<input name="cntyUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getCounty() ?? '' ?>" placeholder="County">
<input name="townUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getTown() ?? '' ?>" placeholder="Town">
<input name="postUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getPostcode() ?? '' ?>" placeholder="Postcode">
<input name="contUpdate" class="form-control rounded mb-2" value="<?= $facilityData->getContributor() ?? '' ?>" placeholder="Contributor">
<button type="submit" class="btn bg-primary btn-outline-primary text-light" name="updateButton">Update</button>
<input type="hidden" name="idUpdate" value="<?= $facilityData->getId()?>">
<div class="modal-body p-4">
<form id="updateForm">
<input type="hidden" name="action" value="update">
<input type="hidden" name="idUpdate" value="">
<div class="mb-3">
<label for="titlUpdate" class="form-label">Title</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-tag text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="titlUpdate" name="titlUpdate" placeholder="Enter facility title" required>
</div>
</div>
<div class="mb-3">
<label for="cateUpdate" class="form-label">Category</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-bookmark text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="cateUpdate" name="cateUpdate" placeholder="Enter facility category" required>
</div>
</div>
<div class="mb-3">
<label for="descUpdate" class="form-label">Description</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-card-text text-success"></i>
</span>
<textarea class="form-control border-start-0" id="descUpdate" name="descUpdate" placeholder="Enter facility description" rows="3" required></textarea>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="hnumUpdate" class="form-label">House/Building Number</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-house text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="hnumUpdate" name="hnumUpdate" placeholder="Enter number" required>
</div>
</div>
<div class="col-md-6 mb-3">
<label for="strtUpdate" class="form-label">Street Name</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-signpost text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="strtUpdate" name="strtUpdate" placeholder="Enter street name" required>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="townUpdate" class="form-label">Town/City</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-building text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="townUpdate" name="townUpdate" placeholder="Enter town/city" required>
</div>
</div>
<div class="col-md-6 mb-3">
<label for="cntyUpdate" class="form-label">County</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-map text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="cntyUpdate" name="cntyUpdate" placeholder="Enter county" required>
</div>
</div>
</div>
<div class="mb-3">
<label for="postUpdate" class="form-label">Postcode</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-mailbox text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="postUpdate" name="postUpdate" placeholder="Enter postcode" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="latUpdate" class="form-label">Latitude</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-geo-alt text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="latUpdate" name="latUpdate" placeholder="Enter latitude" required>
</div>
</div>
<div class="col-md-6 mb-3">
<label for="lngUpdate" class="form-label">Longitude</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-geo-alt text-success"></i>
</span>
<input type="text" class="form-control border-start-0" id="lngUpdate" name="lngUpdate" placeholder="Enter longitude" required>
</div>
</div>
</div>
<div class="mb-3">
<label for="contUpdate" class="form-label">Contributor</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-person text-success"></i>
</span>
<input type="text" class="form-control border-start-0 bg-light" id="contUpdate" name="contUpdate" placeholder="Original contributor" readonly required>
</div>
<small class="text-muted">Original contributor of this facility</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Close</button>
<div class="modal-footer bg-light">
<div class="w-100 d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="updateForm" class="btn btn-success">
<i class="bi bi-check-circle me-1"></i>Update Facility
</button>
</div>
</div>
</div>
</div>

177
auth.php Normal file
View File

@@ -0,0 +1,177 @@
<?php
require_once('Models/AuthService.php');
require_once('Models/UserDataSet.php');
require_once('Models/User.php');
// Enable CORS with more restrictive settings
header('Access-Control-Allow-Origin: *'); // Would be set to domain. Move to .env file
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Content-Type: application/json');
// Add security headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Content-Security-Policy: default-src \'self\'');
// Handle OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
try {
$auth = new AuthService();
$userDataSet = new UserDataSet();
// Handle POST request for login
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
// Handle CAPTCHA generation
if (isset($data['action']) && $data['action'] === 'generateCaptcha') {
// Generate a random 6-character CAPTCHA
$captcha = substr(str_shuffle('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 6);
// Store CAPTCHA in session with timestamp
session_start();
$_SESSION['captcha'] = [
'code' => $captcha,
'timestamp' => time()
];
echo json_encode(['captcha' => $captcha]);
exit;
}
// Handle token refresh
if (isset($data['action']) && $data['action'] === 'refresh') {
if (!isset($data['refreshToken'])) {
http_response_code(400);
echo json_encode(['error' => 'Refresh token is required']);
exit;
}
$refreshToken = $data['refreshToken'];
$newToken = $auth->refreshToken($refreshToken);
if (!$newToken) {
http_response_code(401);
echo json_encode(['error' => 'Invalid or expired refresh token']);
exit;
}
echo json_encode([
'success' => true,
'token' => $newToken
]);
exit;
}
// Handle login
if (!isset($data['username']) || !isset($data['password'])) {
http_response_code(400);
echo json_encode(['error' => 'Username and password are required']);
exit;
}
// Check if CAPTCHA is required
session_start();
$loginAttempts = $_SESSION['login_attempts'] ?? 0;
if ($loginAttempts >= 3) {
// Verify CAPTCHA if required
if (!isset($data['captchaInput']) || !isset($_SESSION['captcha'])) {
http_response_code(400);
echo json_encode(['error' => 'CAPTCHA is required', 'captcha' => true]);
exit;
}
// Check if CAPTCHA is expired (5 minutes)
if (time() - $_SESSION['captcha']['timestamp'] > 300) {
unset($_SESSION['captcha']);
http_response_code(400);
echo json_encode(['error' => 'CAPTCHA expired', 'captcha' => true]);
exit;
}
// Verify CAPTCHA code
if (strtoupper($data['captchaInput']) !== $_SESSION['captcha']['code']) {
unset($_SESSION['captcha']);
http_response_code(400);
echo json_encode(['error' => 'Invalid CAPTCHA', 'captcha' => true]);
exit;
}
// Clear CAPTCHA after successful verification
unset($_SESSION['captcha']);
}
// Authenticate user
$user = new User();
$token = $user->Authenticate($data['username'], hash('sha256', $data['password']));
if ($token) {
// Reset login attempts on successful login
$_SESSION['login_attempts'] = 0;
// Generate refresh token
$refreshToken = $auth->generateRefreshToken([
'id' => $user->getUserId(),
'username' => $user->getUsername(),
'accessLevel' => $user->getAccessLevel()
]);
echo json_encode([
'success' => true,
'token' => $token,
'refreshToken' => $refreshToken,
'user' => [
'id' => $user->getUserId(),
'username' => $user->getUsername(),
'accessLevel' => $user->getAccessLevel()
]
]);
} else {
// Increment login attempts
$_SESSION['login_attempts'] = ($loginAttempts ?? 0) + 1;
http_response_code(401);
echo json_encode([
'error' => 'Invalid credentials',
'captcha' => $_SESSION['login_attempts'] >= 3
]);
}
exit;
}
// Handle GET request for token validation
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$auth = User::checkAuth(false);
if ($auth) {
echo json_encode([
'valid' => true,
'user' => [
'id' => $auth['uid'],
'username' => $auth['username'],
'accessLevel' => $auth['accessLevel']
]
]);
} else {
http_response_code(401);
echo json_encode(['valid' => false, 'error' => 'Invalid or expired token']);
}
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
} catch (Exception $e) {
error_log('Auth error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Server error', 'message' => $e->getMessage()]);
}

4085
css/bootstrap-grid.css vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,597 +0,0 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,594 +0,0 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,347 +0,0 @@
/*!
* Bootstrap v3.1.1 (http://getbootstrap.com)
* Copyright 2011-2014 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
.btn-default,
.btn-primary,
.btn-success,
.btn-info,
.btn-warning,
.btn-danger {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
}
.btn-default:active,
.btn-primary:active,
.btn-success:active,
.btn-info:active,
.btn-warning:active,
.btn-danger:active,
.btn-default.active,
.btn-primary.active,
.btn-success.active,
.btn-info.active,
.btn-warning.active,
.btn-danger.active {
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
}
.btn:active,
.btn.active {
background-image: none;
}
.btn-default {
text-shadow: 0 1px 0 #fff;
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #dbdbdb;
border-color: #ccc;
}
.btn-default:hover,
.btn-default:focus {
background-color: #e0e0e0;
background-position: 0 -15px;
}
.btn-default:active,
.btn-default.active {
background-color: #e0e0e0;
border-color: #dbdbdb;
}
.btn-primary {
background-image: -webkit-linear-gradient(top, #428bca 0%, #2d6ca2 100%);
background-image: linear-gradient(to bottom, #428bca 0%, #2d6ca2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #2b669a;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: #2d6ca2;
background-position: 0 -15px;
}
.btn-primary:active,
.btn-primary.active {
background-color: #2d6ca2;
border-color: #2b669a;
}
.btn-success {
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #3e8f3e;
}
.btn-success:hover,
.btn-success:focus {
background-color: #419641;
background-position: 0 -15px;
}
.btn-success:active,
.btn-success.active {
background-color: #419641;
border-color: #3e8f3e;
}
.btn-info {
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #28a4c9;
}
.btn-info:hover,
.btn-info:focus {
background-color: #2aabd2;
background-position: 0 -15px;
}
.btn-info:active,
.btn-info.active {
background-color: #2aabd2;
border-color: #28a4c9;
}
.btn-warning {
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #e38d13;
}
.btn-warning:hover,
.btn-warning:focus {
background-color: #eb9316;
background-position: 0 -15px;
}
.btn-warning:active,
.btn-warning.active {
background-color: #eb9316;
border-color: #e38d13;
}
.btn-danger {
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #b92c28;
}
.btn-danger:hover,
.btn-danger:focus {
background-color: #c12e2a;
background-position: 0 -15px;
}
.btn-danger:active,
.btn-danger.active {
background-color: #c12e2a;
border-color: #b92c28;
}
.thumbnail,
.img-thumbnail {
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
}
.dropdown-menu > li > a:hover,
.dropdown-menu > li > a:focus {
background-color: #e8e8e8;
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
background-repeat: repeat-x;
}
.dropdown-menu > .active > a,
.dropdown-menu > .active > a:hover,
.dropdown-menu > .active > a:focus {
background-color: #357ebd;
background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%);
background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);
background-repeat: repeat-x;
}
.navbar-default {
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
}
.navbar-default .navbar-nav > .active > a {
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f3f3f3 100%);
background-image: linear-gradient(to bottom, #ebebeb 0%, #f3f3f3 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0);
background-repeat: repeat-x;
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
}
.navbar-brand,
.navbar-nav > li > a {
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
}
.navbar-inverse {
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
}
.navbar-inverse .navbar-nav > .active > a {
background-image: -webkit-linear-gradient(top, #222 0%, #282828 100%);
background-image: linear-gradient(to bottom, #222 0%, #282828 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0);
background-repeat: repeat-x;
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
}
.navbar-inverse .navbar-brand,
.navbar-inverse .navbar-nav > li > a {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
}
.navbar-static-top,
.navbar-fixed-top,
.navbar-fixed-bottom {
border-radius: 0;
}
.alert {
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
}
.alert-success {
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
background-repeat: repeat-x;
border-color: #b2dba1;
}
.alert-info {
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
background-repeat: repeat-x;
border-color: #9acfea;
}
.alert-warning {
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
background-repeat: repeat-x;
border-color: #f5e79e;
}
.alert-danger {
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
background-repeat: repeat-x;
border-color: #dca7a7;
}
.progress {
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar {
background-image: -webkit-linear-gradient(top, #428bca 0%, #3071a9 100%);
background-image: linear-gradient(to bottom, #428bca 0%, #3071a9 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar-success {
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar-info {
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar-warning {
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar-danger {
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
background-repeat: repeat-x;
}
.list-group {
border-radius: 4px;
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
}
.list-group-item.active,
.list-group-item.active:hover,
.list-group-item.active:focus {
text-shadow: 0 -1px 0 #3071a9;
background-image: -webkit-linear-gradient(top, #428bca 0%, #3278b3 100%);
background-image: linear-gradient(to bottom, #428bca 0%, #3278b3 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0);
background-repeat: repeat-x;
border-color: #3278b3;
}
.panel {
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
}
.panel-default > .panel-heading {
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
background-repeat: repeat-x;
}
.panel-primary > .panel-heading {
background-image: -webkit-linear-gradient(top, #428bca 0%, #357ebd 100%);
background-image: linear-gradient(to bottom, #428bca 0%, #357ebd 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);
background-repeat: repeat-x;
}
.panel-success > .panel-heading {
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
background-repeat: repeat-x;
}
.panel-info > .panel-heading {
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
background-repeat: repeat-x;
}
.panel-warning > .panel-heading {
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
background-repeat: repeat-x;
}
.panel-danger > .panel-heading {
background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
background-repeat: repeat-x;
}
.well {
background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
background-repeat: repeat-x;
border-color: #dcdcdc;
-webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1);
}
/*# sourceMappingURL=bootstrap-theme.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12057
css/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12030
css/bootstrap.rtl.css vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,68 +0,0 @@
nav, #loginStatus, #filters {
background-color: #3cc471;
color: #111
}
#content.full-height {
/*height: calc(100vh - 413px);*/
flex: 1 0 auto;
}
.main {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.facilityContent {
overflow-y: auto;
}
#title {
margin-top: 12px;
background-color: #fff;
color: #000;
}
#menu {
border-top: solid 6px #000;
background-color: #fff;
color: #fff;
height: 400px;
}
#menu a {
/*background-color: #f00;*/
color: #fff;
text-decoration: none;
display: block;
}
#menu a:hover {
/*background-color: #f00;*/
color: #ddd;
text-decoration:underline;
display: block;
}
#content {
background-color: #fff;
/*border-top: solid 6px #f00;*/
}
#footer {
margin-top: 20px;
text-align: center;
background-color: #bbb;
color: #111;
}
.modal {
z-index: 1055
}
.modal-backdrop {
z-index: 1040;
}
.site-footer {
flex: 0 0 auto;
}

214
facilitycontroller.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
require_once('Models/AuthService.php');
require_once('Models/FacilityDataSet.php');
require_once('Models/User.php');
// Enable CORS
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Content-Type: application/json');
// Handle OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
try {
$facilityDataSet = new FacilityDataSet();
// Handle POST requests for CRUD operations
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// Set up request data
$request = [
'action' => $action,
'data' => $_POST
];
// Apply different authentication checks based on action
if ($action === 'read' || $action === 'getStatuses') {
// These actions don't require authentication
// No authentication check needed
} else if (in_array($action, ['create', 'update', 'delete', 'editStatus', 'deleteStatus'])) {
// These actions require admin privileges
$auth = User::checkAdmin();
if (!$auth) {
// The checkAdmin method already sent the error response
exit;
}
} else if ($action === 'status') {
// This action requires authentication but not admin privileges
$auth = User::checkAuth();
if (!$auth) {
// The checkAuth method already sent the error response
exit;
}
} else {
// Unknown action
http_response_code(400);
echo json_encode(['error' => 'Invalid action']);
exit;
}
// Process the action
switch ($action) {
case 'read':
$facilities = $facilityDataSet->fetchAll();
if ($facilities) {
echo json_encode(['success' => true, 'facilities' => $facilities]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to fetch facilities']);
}
break;
case 'create':
try {
$data = [
'title' => $_POST['title'],
'category' => $_POST['category'],
'description' => $_POST['description'],
'houseNumber' => $_POST['houseNumber'],
'streetName' => $_POST['streetName'],
'county' => $_POST['county'],
'town' => $_POST['town'],
'postcode' => $_POST['postcode'],
'lng' => $_POST['lng'],
'lat' => $_POST['lat'],
'contributor' => $auth['username']
];
$facility = $facilityDataSet->createFacility($data);
if ($facility) {
echo json_encode(['success' => true, 'facility' => $facility]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to create facility']);
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
break;
case 'update':
try {
$id = $_POST['id'];
$data = [
'title' => $_POST['title'],
'category' => $_POST['category'],
'description' => $_POST['description'],
'houseNumber' => $_POST['houseNumber'],
'streetName' => $_POST['streetName'],
'county' => $_POST['county'],
'town' => $_POST['town'],
'postcode' => $_POST['postcode'],
'lng' => $_POST['lng'],
'lat' => $_POST['lat'],
'contributor' => $auth['username']
];
$facility = $facilityDataSet->updateFacility($id, $data);
if ($facility) {
echo json_encode(['success' => true, 'facility' => $facility]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to update facility']);
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
break;
case 'delete':
$id = $_POST['id'];
if ($facilityDataSet->deleteFacility($id)) {
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to delete facility']);
}
break;
case 'status':
$facilityId = $_POST['facilityId'];
$statusComment = $_POST['statusComment'];
if ($facilityDataSet->addFacilityStatus($facilityId, $statusComment)) {
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to add status']);
}
break;
case 'getStatuses':
if (!isset($_POST['facilityId'])) {
http_response_code(400);
echo json_encode(['error' => 'Facility ID is required']);
break;
}
$facilityId = $_POST['facilityId'];
try {
$statuses = $facilityDataSet->getFacilityStatuses($facilityId);
if ($statuses === false) {
throw new Exception('Failed to fetch facility statuses');
}
echo json_encode([
'success' => true,
'statuses' => $statuses
]);
} catch (Exception $e) {
error_log('Error getting facility statuses: ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'error' => 'Failed to load comments',
'message' => $e->getMessage()
]);
}
break;
case 'editStatus':
$statusId = $_POST['statusId'];
$statusComment = $_POST['statusComment'];
if ($facilityDataSet->updateFacilityStatus($statusId, $statusComment)) {
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to update status']);
}
break;
case 'deleteStatus':
$statusId = $_POST['statusId'];
if ($facilityDataSet->deleteFacilityStatus($statusId)) {
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to delete status']);
}
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Invalid action']);
break;
}
} else {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log('Facility controller error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Server error', 'message' => $e->getMessage()]);
}

0
images/ecoBuddy_x128.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

0
images/ecoBuddy_x128.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

0
images/ecoBuddy_x32.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

0
images/ecoBuddy_x32.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
images/ecoBuddy_x64.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

0
images/ecoBuddy_x64.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

14
index.php Normal file → Executable file
View File

@@ -1,15 +1,19 @@
<?php
// load dataset
require_once('Models/UserDataSet.php');
require_once('Models/FacilityDataSet.php');
// make a view class
$view = new stdClass();
$view->pageTitle = 'Home';
// load login controller and pagination controller
require_once("logincontroller.php");
require_once('paginationcontroller.php');
// initialise facility data
$facilityDataSet = new FacilityDataSet();
$view->facilityDataSet = $facilityDataSet->fetchAll();
$view->user = new User();
// Log any critical errors
if ($view->facilityDataSet === false) {
error_log('Error fetching facility data');
}
// load main view
require_once('Views/index.phtml');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4447
js/bootstrap.esm.js vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4494
js/bootstrap.js vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
js/bootstrap.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,52 +0,0 @@
<?php
require_once("Models/User.php");
// create user and dataset object
$user = new User();
$userDataSet = new UserDataSet();
if (isset($_POST["loginButton"])) {
$username = $_POST["username"];
// hash password
$password = (hash("sha256", $_POST["password"]));
// if login error, show captcha
if (isset($view->loginError)) {
$generatedCaptcha = $_POST["generatedCaptcha"];
$userCaptcha = $_POST["captcha"];
// if captcha wrong, say so
if ($generatedCaptcha !== $userCaptcha) {
$view->loginError = "Incorrect CAPTCHA.";
return;
}
}
// create a new student dataset object that we can generate data from
// Error handling is VERY hacky, because of the lack of JS usage.
if($userDataSet->checkUserCredentials($username, $password)) {
$user->Authenticate($username, $password);
// Unset modal boolean to hide it's usage.
unset($_GET['modal']);
} else {
// Add error message and redirect to display modal
$view->loginError = "Invalid username or password.";
// Set modal boolean to header to allow modal to reappear
$queryParams = http_build_query(['modal' => 'true']);
header("Location: {$_SERVER['PHP_SELF']}?$queryParams");
exit;
}
}
if(isset($_POST['closeButton'])) {
unset($_GET['modal']);
}
if (isset($_POST["logoutButton"]))
{
$user->logout();
}
// for login errors; show login modal until captcha solved
if (isset($_GET['modal']) && $_GET['modal'] === 'true') {
$view->loginError = $view->loginError ?? "Please solve the Captcha and try again.";
}

19
map.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
// load dataset
require_once('Models/FacilityDataSet.php');
// make a view class
$view = new stdClass();
$view->pageTitle = 'Map';
// initialise facility data
$facilityDataSet = new FacilityDataSet();
$view->facilityDataSet = $facilityDataSet->fetchAll();
// Log any critical errors
if ($view->facilityDataSet === false) {
error_log('Error fetching facility data');
}
// load main view
require_once('Views/map.phtml');

View File

@@ -1,112 +0,0 @@
<?php
require_once('Models/FacilityDataSet.php');
require_once("Models/Paginator.php");
// Default Filters
$filters = [
'category' => $_GET['category'] ?? '1', // Default category
'term' => $_GET['term'] ?? '', // Default term
'sort' => $_GET['sort'] ?? '1', // Default sort
'dir' => $_GET['dir'] ?? 'asc', // Default direction
'page' => $_GET['page'] ?? 0 // Default to first page
];
// If no query parameters exist (initial page load), redirect to set default ones
if (empty($_GET)) {
redirectWithFilters($filters);
}
// Set row limit
$rowLimit = 7;
// create dataset object
$facilityDataSet = new FacilityDataSet();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
/**
* Unfortunately, ZERO time to fix this, too complex.
*/
if(isset($_POST['updateButton'])) {
$data = [
'id' => $_POST['idUpdate'],
'title' => $_POST['titlUpdate'],
'category' => $_POST['cateUpdate'],
'description' => $_POST['descUpdate'],
'houseNumber' => $_POST['hnumUpdate'],
'streetName' => $_POST['strtUpdate'],
'county' => $_POST['cntyUpdate'],
'town' => $_POST['townUpdate'],
'postcode' => $_POST['postUpdate'],
'lng' => $_POST['lngUpdate'],
'lat' => $_POST['latUpdate'],
'contributor' => $_POST['contUpdate'],
];
$facilityDataSet->addFacility($data);
}
if(isset($_POST['createButton'])) {
$data = [
'title' => $_POST['titlCreate'],
'category' => $_POST['cateCreate'],
'description' => $_POST['descCreate'],
'houseNumber' => $_POST['hnumCreate'],
'streetName' => $_POST['strtCreate'],
'county' => $_POST['cntyCreate'],
'town' => $_POST['townCreate'],
'postcode' => $_POST['postCreate'],
'contributor' => $_POST['contCreate'],
];
$facilityDataSet->addFacility($data);
}
// passes id to delete facility
if (isset($_POST['deleteButton'])) {
$facilityDataSet->deleteFacility($_POST['id']);
}
// Check if filters/sorting changed
$filtersChanged = (
$filters['category'] !== ($_POST['filterCat'] ?? $filters['category']) ||
$filters['term'] !== ($_POST['filter'] ?? $filters['term']) ||
$filters['sort'] !== ($_POST['sort'] ?? $filters['sort']) ||
$filters['dir'] !== ($_POST['dir'] ?? $filters['dir'])
);
// load from post if exists and sanitise, otherwise use defaults
$filters['category'] = filter_input(INPUT_POST, 'filterCat', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? $filters['category'];
$filters['term'] = filter_input(INPUT_POST, 'filter', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? $filters['term'];
$filters['sort'] = filter_input(INPUT_POST, 'sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? $filters['sort'];
$filters['dir'] = filter_input(INPUT_POST, 'dir', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? $filters['dir'];
// Reset page if filters changed
$filters['page'] = $filtersChanged ? 0 : $_POST['paginationButton'] ?? $filters['page'];
redirectWithFilters($filters);
}
// fetch page data from database
$view->allPageData = $facilityDataSet->fetchAll(
['category' => $filters['category'], 'term' => $filters['term']],
['sort' => $filters['sort'], 'dir' => $filters['dir']]
);
// set total facility count to view
$view->totalResults = $view->allPageData['count'];
// create paginator object
$view->paginator = new Paginator($rowLimit, $view->allPageData);
// assign page number to view
$view->pageNumber = $view->paginator->getPageFromUri();
// get current page
$view->pageData = $view->paginator->getPage($view->pageNumber);
// Send result count to view in format "showing x of y results"
$view->dbMessage = $view->paginator->countPageResults($view->pageNumber) == 0
? "No results"
: "Showing " . $view->paginator->countPageResults($view->pageNumber) . " of " . $view->totalResults . " result(s)";
// Redirect function, adds header parameters
function redirectWithFilters($filters) {
// Ensure no unintended keys are passed
$allowedKeys = ['category', 'term', 'sort', 'dir', 'page'];
$filters = array_filter($filters, function($key) use ($allowedKeys) {
return in_array($key, $allowedKeys);
}, ARRAY_FILTER_USE_KEY);
$queryString = http_build_query($filters);
header("Location: ?" . $queryString);
exit;
}

4
css/bootstrap-icons.css → public/css/bootstrap-icons.css vendored Normal file → Executable file
View File

@@ -7,8 +7,8 @@
@font-face {
font-display: block;
font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
src: url("fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
}
.bi::before,

View File

5
public/css/bootstrap-icons.min.css vendored Executable file

File diff suppressed because one or more lines are too long

0
css/bootstrap-icons.scss → public/css/bootstrap-icons.scss vendored Normal file → Executable file
View File

290
public/css/default.css Executable file
View File

@@ -0,0 +1,290 @@
nav, #loginStatus, #filters {
background-color: #3cc471;
color: #111
}
#content.full-height {
/*height: calc(100vh - 413px);*/
flex: 1 0 auto;
}
.main {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.facilityContent {
overflow-y: auto;
}
#title {
margin-top: 12px;
background-color: #fff;
color: #000;
}
#menu {
border-top: solid 6px #000;
background-color: #fff;
color: #fff;
height: 400px;
}
#menu a {
/*background-color: #f00;*/
color: #fff;
text-decoration: none;
display: block;
}
#menu a:hover {
/*background-color: #f00;*/
color: #ddd;
text-decoration:underline;
display: block;
}
#content {
background-color: #fff;
/*border-top: solid 6px #f00;*/
}
#footer {
margin-top: 20px;
text-align: center;
background-color: #bbb;
color: #111;
}
.modal {
z-index: 1055
}
.modal-backdrop {
z-index: 1040;
}
.site-footer {
flex: 0 0 auto;
}
td { white-space:pre-line }
/* Enhanced Facility Table Styles */
#facilityTable {
border-collapse: separate;
border-spacing: 0;
font-size: 0.9rem;
table-layout: fixed;
width: 100%;
}
#facilityTable thead th {
border-bottom: 1px solid #dee2e6;
font-weight: 600;
color: #495057;
font-size: 0.85rem;
padding: 0.5rem;
position: relative;
overflow: hidden;
}
#facilityTable tbody tr {
transition: all 0.2s ease;
}
#facilityTable tbody tr:hover {
background-color: rgba(60, 196, 113, 0.05);
}
#facilityTable tbody td {
vertical-align: middle;
border-bottom: 1px solid #f0f0f0;
line-height: 1.3;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
padding: 0.5rem;
position: relative;
}
/* Prevent content overflow */
#facilityTable th,
#facilityTable td {
box-sizing: border-box;
overflow: hidden;
}
.facility-icon {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.description-container {
position: relative;
max-width: 100%;
}
.description-container p {
line-height: 1.4;
color: #495057;
margin-bottom: 0;
word-wrap: break-word;
overflow-wrap: break-word;
}
.description-container p.expanded {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.show-more-btn {
color: #3cc471;
font-size: 0.75rem;
}
.show-more-btn:hover {
color: #2a9d55;
}
/* Badge styling */
.badge.bg-opacity-10 {
font-weight: 500;
letter-spacing: 0.3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
font-size: 0.75rem;
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
/* Action buttons */
.btn.rounded-circle {
width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.btn.rounded-circle:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Ensure action buttons are properly aligned */
.d-flex.justify-content-center.gap-1 {
flex-wrap: nowrap;
}
/* Toggle button styling */
.toggle-content-btn {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #3cc471;
}
.toggle-content-btn:hover {
color: #2a9d55;
}
/* Pagination styling */
.pagination .page-link {
color: #3cc471;
border-color: #e9ecef;
}
.pagination .page-item.active .page-link {
background-color: #3cc471;
border-color: #3cc471;
color: white;
}
.pagination .page-link:hover {
background-color: #f8f9fa;
border-color: #e9ecef;
color: #2a9d55;
}
/* Card styling */
.card.shadow {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
}
/* Text truncation with ellipsis */
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
/* Cell content wrapping */
.cell-content {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
max-height: 3.9em; /* Approximately 3 lines of text */
overflow: hidden;
position: relative;
width: 100%;
}
.address-content {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
max-height: 2.6em; /* Approximately 2 lines of text */
overflow: hidden;
width: 100%;
}
/* Ensure title column has proper alignment */
.fw-medium .d-flex {
align-items: center;
width: 100%;
}
/* Fix for coordinates column */
.text-nowrap {
white-space: nowrap !important;
}
.navbar-brand img {
transition: transform 0.3s ease;
}
.navbar-brand:hover img {
transform: scale(1.05);
}
.search-controls {
max-width: 800px;
}
.form-control:focus, .form-select:focus {
border-color: #198754;
box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);
}
.user-avatar {
width: 32px;
height: 32px;
background-color: #e9ecef;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
}
.user-menu {
display: flex;
align-items: center;
}
@media (max-width: 992px) {
.search-controls {
margin-top: 1rem;
margin-bottom: 1rem;
}
}

278
public/js/apiClient.js Normal file
View File

@@ -0,0 +1,278 @@
/**
* API Client for making authenticated requests to the server
*
* This class provides a wrapper around the Fetch API to handle
* authentication and common request patterns.
*
* The client uses JWT tokens for authentication, which are automatically
* included in requests via the fetchAuth function provided by the auth service.
*
* Similar to AuthService.php, great pain and countless tears. And learning woooo!!!!!!!!
*/
class ApiClient {
/**
* Constructor
*
* Initialises the API client and sets up the authenticated fetch function.
* Relies on the auth service being available in the global scope.
*/
constructor() {
// Ensure auth service is available
if (!auth) {
console.error('Auth service not available');
}
// Use the fetchAuth method from auth
this.authFetch = async (url, options = {}) => {
try {
// For unauthenticated requests or when authentication is not required
if (!options.requireAuth || !auth.isAuthenticated()) {
return fetch(url, options);
}
// For authenticated requests
delete options.requireAuth; // Remove the custom property
return auth.fetchAuth(url, options);
} catch (error) {
console.error('Error in authFetch:', error);
throw error;
}
};
}
/**
* Makes a GET request to the API
*
* This method handles GET requests with query parameters.
* It automatically converts the params object to a query string
* and handles error responses.
*
* @param {string} endpoint - The API endpoint
* @param {Object} params - Query parameters
* @returns {Promise<Object>} The response data
*/
async get(endpoint, params = {}) {
// Build query string
const queryString = Object.keys(params).length > 0
? '?' + new URLSearchParams(params).toString()
: '';
try {
const response = await this.authFetch(`${endpoint}${queryString}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`GET request to ${endpoint} failed:`, error);
throw error;
}
}
/**
* Makes a POST request to the API
*
* This method handles POST requests with either JSON data or FormData.
* It automatically sets the appropriate headers and handles error responses.
*
* @param {string} endpoint - The API endpoint
* @param {Object|FormData} data - The data to send
* @returns {Promise<Object>} The response data
*/
async post(endpoint, data = {}) {
try {
// Prepare request options
const options = {
method: 'POST'
};
// Handle FormData or JSON
if (data instanceof FormData) {
options.body = data;
} else {
options.headers = {
'Content-Type': 'application/json'
};
options.body = JSON.stringify(data);
}
const response = await this.authFetch(endpoint, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`POST request to ${endpoint} failed:`, error);
throw error;
}
}
/**
* Makes a facility-related API request
*
* This is a helper method that simplifies making requests to the facility controller.
* It automatically creates a FormData object with the action and data parameters.
*
* @param {string} action - The action to perform
* @param {Object} data - The data to send
* @returns {Promise<Object>} The response data
*/
async facility(action, data = {}) {
// Create FormData
const formData = new FormData();
formData.append('action', action);
// Add all data to FormData
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
});
try {
// Validate auth state before making request
if (!this.authFetch) {
throw new Error('Auth fetch not available');
}
if (action === 'status' && (!data.facilityId || !data.statusComment)) {
throw new Error('Missing required data for status update');
}
// Use authenticated fetch for all facility requests
const response = await this.authFetch('/facilitycontroller.php', {
method: 'POST',
body: formData,
requireAuth: true
});
// Parse the response
const jsonData = await response.json();
// Check if response is ok
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}, message: ${jsonData.error || 'Unknown error'}`);
}
return jsonData;
} catch (error) {
console.error('Facility API error:', error);
throw error;
}
}
/**
* Creates a new facility
*
* This method sends a request to create a new facility with the provided data.
*
* @param {Object} facilityData - The facility data
* @returns {Promise<Object>} The response data
*/
async createFacility(facilityData) {
return this.facility('create', facilityData);
}
/**
* Updates a facility
*
* This method sends a request to update an existing facility with the provided data.
*
* @param {Object} facilityData - The facility data
* @returns {Promise<Object>} The response data
*/
async updateFacility(facilityData) {
return this.facility('update', facilityData);
}
/**
* Deletes a facility
*
* This method sends a request to delete a facility with the specified ID.
*
* @param {number|string} id - The facility ID
* @returns {Promise<Object>} The response data
*/
async deleteFacility(id) {
return this.facility('delete', { id });
}
/**
* Gets a facility by ID
*
* This method retrieves a single facility with the specified ID.
*
* @param {number|string} id - The facility ID
* @returns {Promise<Object>} The response data
*/
async getFacility(id) {
return this.facility('read', { id });
}
/**
* Gets statuses for a facility
*
* This method retrieves all status updates for a facility with the specified ID.
*
* @param {number|string} facilityId - The facility ID
* @returns {Promise<Object>} The response data
*/
async getFacilityStatuses(facilityId) {
return this.facility('getStatuses', { facilityId });
}
/**
* Adds a status to a facility
*
* This method adds a new status update to a facility.
*
* @param {number|string} facilityId - The facility ID
* @param {string} statusComment - The status comment
* @returns {Promise<Object>} The response data
*/
async addFacilityStatus(facilityId, statusComment) {
return this.facility('status', {
facilityId: facilityId,
statusComment: statusComment
});
}
/**
* Updates a facility status
*
* This method updates an existing status for a facility.
*
* @param {number|string} statusId - The status ID
* @param {string} editStatus - The updated status comment
* @param {number|string} facilityId - The facility ID
* @returns {Promise<Object>} The response data
*/
async updateFacilityStatus(statusId, editStatus, facilityId) {
return this.facility('editStatus', {
statusId: statusId,
statusComment: editStatus,
facilityId: facilityId
});
}
/**
* Deletes a facility status
*
* This method deletes a status update from a facility.
*
* @param {number|string} statusId - The status ID
* @param {number|string} facilityId - The facility ID
* @returns {Promise<Object>} The response data
*/
async deleteFacilityStatus(statusId, facilityId) {
return this.facility('deleteStatus', { statusId, facilityId });
}
}
// initialise API client
const api = new ApiClient();
// Export API client
window.api = api;

369
public/js/auth.js Normal file
View File

@@ -0,0 +1,369 @@
/**
* Authentication Worker
*
* I admit JWT is unnecessary, but I did it anyway because it was interesting
* and I wanted to try it out.
*/
class Auth {
/**
* initialise the authentication helper
*/
constructor() {
this.token = localStorage.getItem('token');
this.user = JSON.parse(localStorage.getItem('user') || 'null');
this.loginAttempts = parseInt(localStorage.getItem('loginAttempts') || '0');
this.isValidating = false;
this.validationPromise = null;
// Generate a browser fingerprint
this.browserFingerprint = this._generateFingerprint();
// Check if the stored fingerprint matches the current browser
const storedFingerprint = localStorage.getItem('browserFingerprint');
if (this.token && (!storedFingerprint || storedFingerprint !== this.browserFingerprint)) {
// Fingerprint mismatch - potential token theft
console.warn('Browser fingerprint mismatch - clearing authentication');
this.logout(false); // Silent logout (no redirect)
}
}
/**
* Generate a simple browser fingerprint, super unnecessary and out of scope
* but it was simple and hardens the authentication a bit.
* @private
* @returns {string} A fingerprint based on browser properties
*/
_generateFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.colorDepth,
screen.width + 'x' + screen.height,
new Date().getTimezoneOffset()
];
// Create a hash of the components
let hash = 0;
const str = components.join('|');
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return hash.toString(16);
}
/**
* Validate token on page load, this is to prevent XSS attacks. (During testing
* copying the tokens and userdata, and setting the localStorage manually on a
* new browser automatically logged me in.)
* This should be called when the page loads to ensure the token is valid
* @returns {Promise<boolean>} True if token is valid, false otherwise
*/
async validateOnLoad() {
// If already validating, return the existing promise
if (this.isValidating) {
return this.validationPromise;
}
// If no token, no need to validate since not logged in
if (!this.token) {
return false;
}
// Set validating flag and create promise
this.isValidating = true;
this.validationPromise = (async () => {
try {
const isValid = await this.validateToken();
if (!isValid) {
// Token is invalid, try to refresh it
const refreshed = await this.refreshToken();
if (!refreshed) {
// Refresh failed, logout
this.logout(false); // Silent logout (no redirect)
return false;
}
return true;
}
return isValid;
} catch (error) {
console.error('Token validation error:', error);
this.logout(false); // Silent logout (no redirect)
return false;
} finally {
this.isValidating = false;
this.validationPromise = null;
}
})();
return this.validationPromise;
}
/**
* Parse a JWT token to extract its payload
* @param {string} token - The JWT token to parse
* @returns {object|null} The decoded payload or null if invalid
*/
parseJwt(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
console.error('Error parsing JWT token:', e);
return null;
}
}
/**
* Generate a new CAPTCHA
* @returns {Promise<string>} The generated CAPTCHA code
*/
async generateCaptcha() {
try {
const response = await fetch('/auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'generateCaptcha'
})
});
const data = await response.json();
if (data.captcha) {
return data.captcha;
}
throw new Error('Failed to generate CAPTCHA');
} catch (error) {
console.error('Error generating CAPTCHA:', error);
throw error;
}
}
/**
* Check if CAPTCHA is needed for login
* @returns {boolean} True if CAPTCHA is needed, false otherwise
*/
needsCaptcha() {
return this.loginAttempts >= 3;
}
/**
* Login a user based on credentials.
* @param {object} credentials - The user credentials (username, password, captchaInput)
* @returns {Promise<object>} The login result
*/
async login(credentials) {
try {
const response = await fetch('/auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
if (!response.ok) {
// If CAPTCHA is required, include it in the error
if (data.captcha) {
throw new Error(data.error || 'Login failed');
} else {
throw new Error(data.error || 'Login failed');
}
}
// Store token and user data
this.token = data.token;
localStorage.setItem('token', data.token);
// Store refresh token if available
if (data.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
// Reset login attempts
this.loginAttempts = 0;
localStorage.setItem('loginAttempts', '0');
// Store user data
this.user = data.user;
localStorage.setItem('user', JSON.stringify(data.user));
// Store browser fingerprint
localStorage.setItem('browserFingerprint', this.browserFingerprint);
return {
success: true,
user: this.user
};
} catch (error) {
console.error('Login error:', error);
// Increment login attempts
this.loginAttempts++;
localStorage.setItem('loginAttempts', this.loginAttempts.toString());
return {
success: false,
error: error.message,
captcha: error.captcha
};
}
}
/**
* Logout the current user
* @param {boolean} redirect - Whether to redirect to home page after logout (default: true)
*/
logout(redirect = true) {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
localStorage.removeItem('browserFingerprint');
// Redirect to home page if requested
if (redirect) {
window.location.href = '/';
}
}
/**
* Check if the user is authenticated
* @returns {boolean} True if authenticated, false otherwise
*/
isAuthenticated() {
return !!this.token && !!this.user;
}
/**
* Check if the user is an admin
* @returns {boolean} True if admin, false otherwise
*/
isAdmin() {
return this.isAuthenticated() && (this.user.accessLevel === 1 || this.user.accessLevel === 0);
}
/**
* Get the current user
* @returns {object|null} The current user or null if not authenticated
*/
getUser() {
return this.user;
}
/**
* Get the authentication token
* @returns {string|null} The token or null if not authenticated
*/
getToken() {
return this.token;
}
/**
* Make an authenticated API request
* @param {string} url - The URL to fetch
* @param {object} options - Fetch options
* @returns {Promise<Response>} The fetch response
*/
async fetchAuth(url, options = {}) {
if (!this.token) {
throw new Error('Not authenticated');
}
const headers = {
...options.headers,
'Authorization': `Bearer ${this.token}`
};
return fetch(url, {
...options,
headers
});
}
/**
* Validate the current token
* @returns {Promise<boolean>} True if token is valid, false otherwise
*/
async validateToken() {
try {
if (!this.token) {
return false;
}
const response = await fetch('/auth.php', {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`
}
});
const data = await response.json();
return data.valid === true;
} catch (error) {
console.error('Token validation error:', error);
return false;
}
}
/**
* Refresh the access token using the refresh token
* @returns {Promise<boolean>} True if token was refreshed, false otherwise
*/
async refreshToken() {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
return false;
}
const response = await fetch('/auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'refresh',
refreshToken
})
});
const data = await response.json();
if (data.success && data.token) {
this.token = data.token;
localStorage.setItem('token', data.token);
return true;
}
return false;
} catch (error) {
console.error('Token refresh error:', error);
return false;
}
}
}
// Create a global instance and expose it
window.auth = new Auth();
// Log that auth is ready
console.log('auth is ready and exposed to window');
// Dispatch a custom event to notify other scripts
window.dispatchEvent(new Event('authReady'));

View File

@@ -1507,7 +1507,7 @@
this._triggerArray.push(elem);
}
}
this._initializeChildren();
this._initialiseChildren();
if (!this._config.parent) {
this._addAriaAndCollapsedClass(this._triggerArray, this._isShown());
}
@@ -1617,7 +1617,7 @@
_getDimension() {
return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT;
}
_initializeChildren() {
_initialiseChildren() {
if (!this._config.parent) {
return;
}
@@ -4265,8 +4265,8 @@
constructor(element, config) {
super(element, config);
this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element);
this._backdrop = this._initializeBackDrop();
this._focustrap = this._initializeFocusTrap();
this._backdrop = this._initialiseBackDrop();
this._focustrap = this._initialiseFocusTrap();
this._isShown = false;
this._isTransitioning = false;
this._scrollBar = new ScrollBarHelper();
@@ -4331,14 +4331,14 @@
}
// Private
_initializeBackDrop() {
_initialiseBackDrop() {
return new Backdrop({
isVisible: Boolean(this._config.backdrop),
// 'static' option will be translated to true, and booleans will keep their value,
isAnimated: this._isAnimated()
});
}
_initializeFocusTrap() {
_initialiseFocusTrap() {
return new FocusTrap({
trapElement: this._element
});
@@ -4567,8 +4567,8 @@
constructor(element, config) {
super(element, config);
this._isShown = false;
this._backdrop = this._initializeBackDrop();
this._focustrap = this._initializeFocusTrap();
this._backdrop = this._initialiseBackDrop();
this._focustrap = this._initialiseFocusTrap();
this._addEventListeners();
}
@@ -4648,7 +4648,7 @@
}
// Private
_initializeBackDrop() {
_initialiseBackDrop() {
const clickCallback = () => {
if (this._config.backdrop === 'static') {
EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED);
@@ -4667,7 +4667,7 @@
clickCallback: isVisible ? clickCallback : null
});
}
_initializeFocusTrap() {
_initialiseFocusTrap() {
return new FocusTrap({
trapElement: this._element
});
@@ -5273,7 +5273,7 @@
}
// Private
_initializeOnDelegatedTarget(event) {
_initialiseOnDelegatedTarget(event) {
return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig());
}
_isAnimated() {
@@ -5346,19 +5346,19 @@
for (const trigger of triggers) {
if (trigger === 'click') {
EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event);
const context = this._initialiseOnDelegatedTarget(event);
context.toggle();
});
} else if (trigger !== TRIGGER_MANUAL) {
const eventIn = trigger === TRIGGER_HOVER ? this.constructor.eventName(EVENT_MOUSEENTER) : this.constructor.eventName(EVENT_FOCUSIN$1);
const eventOut = trigger === TRIGGER_HOVER ? this.constructor.eventName(EVENT_MOUSELEAVE) : this.constructor.eventName(EVENT_FOCUSOUT$1);
EventHandler.on(this._element, eventIn, this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event);
const context = this._initialiseOnDelegatedTarget(event);
context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true;
context._enter();
});
EventHandler.on(this._element, eventOut, this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event);
const context = this._initialiseOnDelegatedTarget(event);
context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = context._element.contains(event.relatedTarget);
context._leave();
});
@@ -5637,7 +5637,7 @@
visibleEntryTop: 0,
parentScrollTop: 0
};
this.refresh(); // initialize
this.refresh(); // initialise
}
// Getters
@@ -5653,7 +5653,7 @@
// Public
refresh() {
this._initializeTargetsAndObservables();
this._initialiseTargetsAndObservables();
this._maybeEnableSmoothScroll();
if (this._observer) {
this._observer.disconnect();
@@ -5749,7 +5749,7 @@
}
}
}
_initializeTargetsAndObservables() {
_initialiseTargetsAndObservables() {
this._targetLinks = new Map();
this._observableSections = new Map();
const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target);
@@ -6089,7 +6089,7 @@
});
/**
* Initialize on focus
* initialise on focus
*/
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE)) {

547
public/js/comments.js Normal file
View File

@@ -0,0 +1,547 @@
/**
* Facility status (comments) manager for adding, removing and editing user comments.
*/
// Create a namespace to avoid global scope conflicts with facilityData.js
const CommentsManager = {
// Initialization states
state: {
isInitializing: false,
isInitialised: false,
isDomReady: false,
isAuthReady: false
},
/**
* initialise status functionality
*/
initialise() {
if (this.state.isInitialised) return;
console.log('Initializing comments...');
// initialise comment modal handlers
this.initialiseCommentModals();
// Set up form handlers
this.setupCommentFormHandlers();
console.log('Comments initialised with auth state:', {
isAuthenticated: this.isAuthenticated(),
user: window.auth.getUser()
});
this.state.isInitialised = true;
},
/**
* Check if initialisation possible
*/
checkinitialise() {
if (this.state.isDomReady && this.state.isAuthReady && !this.state.isInitializing) {
this.state.isInitializing = true;
this.initialise();
this.state.isInitializing = false;
}
},
/**
* Check if user is authenticated
*/
isAuthenticated() {
return window.auth && window.auth.isAuthenticated();
},
/**
* initialise comment modals
*/
initialiseCommentModals() {
// Status modal (comments view)
const statusModal = document.getElementById('statusModal');
if (statusModal) {
statusModal.addEventListener('show.bs.modal', (event) => {
// Get facility ID from either the button or the modal's data attribute
let facilityId;
// First try to get it from the button that triggered the modal
if (event.relatedTarget) {
facilityId = event.relatedTarget.getAttribute('data-facility-id');
}
// If not found in button, try the modal's data attribute
if (!facilityId && statusModal.hasAttribute('data-facility-id')) {
facilityId = statusModal.getAttribute('data-facility-id');
}
if (!facilityId) {
console.error('No facility ID found for comments');
return;
}
// Store the facility ID on the modal for later use
statusModal.setAttribute('data-facility-id', facilityId);
// Load facility comments
this.loadFacilityComments(facilityId);
});
}
// Edit comment modal
const editCommentModal = document.getElementById('editCommentModal');
if (editCommentModal) {
editCommentModal.addEventListener('show.bs.modal', (event) => {
const button = event.relatedTarget;
const commentId = button.getAttribute('data-comment-id');
const commentText = button.getAttribute('data-comment-text');
// Set the comment ID and text in the form
const editForm = document.getElementById('editCommentForm');
if (editForm) {
const commentIdInput = editForm.querySelector('#editCommentId');
const commentTextArea = editForm.querySelector('#editCommentText');
if (commentIdInput && commentTextArea) {
commentIdInput.value = commentId;
commentTextArea.value = commentText;
}
}
});
}
},
/**
* Set up comment form handlers
*/
setupCommentFormHandlers() {
// Comment form handler
const commentForm = document.getElementById('commentForm');
if (commentForm) {
this.setupCommentFormHandler(commentForm);
}
// Edit comment form handler
const editCommentForm = document.getElementById('editCommentForm');
if (editCommentForm) {
this.setupEditCommentFormHandler(editCommentForm);
}
},
/**
* Set up a single comment form handler
*/
setupCommentFormHandler(commentForm) {
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Prevent duplicate submissions
if (commentForm.submitting) {
return;
}
commentForm.submitting = true;
// Check if user is authenticated
if (!this.isAuthenticated()) {
alert('You must be logged in to add comments');
commentForm.submitting = false;
return;
}
const formData = new FormData(commentForm);
// Get form data and ensure proper types
const statusComment = formData.get('commentText');
const facilityId = formData.get('facilityId');
// Validate form data
if (!facilityId) {
console.error('No facility ID found in form');
alert('Error: No facility ID found');
commentForm.submitting = false;
return;
}
if (!statusComment) {
alert('Please enter a comment');
commentForm.submitting = false;
return;
}
try {
// Use the API client to add a status comment
const data = await window.api.addFacilityStatus(facilityId.toString(), statusComment);
if (data.success) {
// Reset the form
commentForm.reset();
// Reload comments to show the new one
this.loadFacilityComments(facilityId.toString());
} else {
console.error('Comment failed:', data.error);
alert(data.error || 'Failed to add comment');
}
} catch (error) {
console.error('Error adding comment:', error);
alert('Failed to add comment: ' + error.message);
} finally {
commentForm.submitting = false;
}
});
},
/**
* Set up a single edit comment form handler
*/
setupEditCommentFormHandler(editCommentForm) {
editCommentForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Prevent duplicate submissions
if (editCommentForm.submitting) {
return;
}
editCommentForm.submitting = true;
// Check if user is authenticated
if (!this.isAuthenticated()) {
alert('You must be logged in to edit comments');
editCommentForm.submitting = false;
return;
}
const formData = new FormData(editCommentForm);
// Get form data
const commentText = formData.get('editCommentText');
const commentId = formData.get('commentId');
const facilityId = document.getElementById('commentFacilityId').value;
console.log('Edit comment form data:', { commentId, facilityId, commentText });
try {
console.log('Sending edit comment request...');
// Use the API client to update a status comment
const data = await window.api.updateFacilityStatus(commentId, commentText, facilityId);
console.log('Edit comment response:', data);
if (data.success) {
console.log('Comment edited successfully');
// Close the edit modal
const editModal = bootstrap.Modal.getInstance(document.getElementById('editCommentModal'));
if (editModal) {
editModal.hide();
}
// Reload comments to show the updated one
this.loadFacilityComments(facilityId);
} else {
console.error('Edit comment failed:', data.error);
alert(data.error || 'Failed to edit comment');
}
} catch (error) {
console.error('Error editing comment:', error);
alert('Failed to edit comment: ' + error.message);
} finally {
editCommentForm.submitting = false;
}
});
},
/**
* Creates a comment form dynamically for authenticated users
*/
createCommentFormForAuthenticatedUser(facilityId) {
// First check if auth is available
if (!window.auth) {
return `
<div class="alert alert-warning mb-0">
<i class="bi bi-hourglass-split me-2"></i>
Loading authentication status...
</div>
`;
}
// Validate authentication state
try {
const token = window.auth.getToken();
const user = window.auth.getUser();
const isAuthenticated = window.auth.isAuthenticated();
if (!isAuthenticated || !token || !user) {
return `
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Please <a href="#" data-bs-toggle="modal" data-bs-target="#loginModal">login</a> to add comments.
</div>
`;
}
// User is authenticated, create the comment form
return `
<form id="commentForm" class="mt-3">
<input type="hidden" id="commentFacilityId" name="facilityId" value="${this.escapeHtml(facilityId)}">
<div class="mb-3">
<label for="commentText" class="form-label">Add a Comment</label>
<textarea class="form-control" id="commentText" name="commentText" rows="3" required></textarea>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-success">
<i class="bi bi-chat-dots-fill me-1"></i>
Add Comment
</button>
</div>
</form>
`;
} catch (error) {
console.error('Error checking authentication:', error);
return `
<div class="alert alert-danger mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
Error checking authentication status. Please try refreshing the page.
</div>
`;
}
},
/**
* Loads facility comments from the server
*/
async loadFacilityComments(facilityId) {
try {
if (!facilityId) {
throw new Error('No facility ID provided');
}
// Ensure facilityId is a string
facilityId = facilityId.toString();
// Show loading indicator
const commentsContainer = document.getElementById('commentsContainer');
if (!commentsContainer) {
throw new Error('Comments container not found');
}
commentsContainer.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading comments...</p>
</div>
`;
// Use the API client to get facility statuses
const data = await window.api.getFacilityStatuses(facilityId);
// Validate the response
if (!data || typeof data !== 'object') {
throw new Error('Invalid response from server');
}
if (!data.success) {
throw new Error(data.error || 'Failed to load comments');
}
if (!Array.isArray(data.statuses)) {
throw new Error('Invalid comments data format');
}
// Render the comments
this.renderComments(data.statuses, facilityId);
} catch (error) {
console.error('Error loading comments:', error);
const commentsContainer = document.getElementById('commentsContainer');
if (commentsContainer) {
commentsContainer.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
${error.message}
</div>
`;
}
}
},
/**
* Renders comments in the comments container
*/
renderComments(comments, facilityId) {
const commentsContainer = document.getElementById('commentsContainer');
if (!commentsContainer) {
console.error('Comments container not found');
return;
}
// Clear the container
commentsContainer.innerHTML = '';
// Add the comment form for authenticated users
commentsContainer.innerHTML = this.createCommentFormForAuthenticatedUser(facilityId);
// Re-initialise the comment form handler immediately after creating the form
const commentForm = document.getElementById('commentForm');
if (commentForm) {
this.setupCommentFormHandler(commentForm);
}
// If no comments, show a message
if (!comments || comments.length === 0) {
const noCommentsDiv = document.createElement('div');
noCommentsDiv.className = 'alert alert-light mt-3';
noCommentsDiv.innerHTML = `
<i class="bi bi-chat-dots me-2"></i>
No comments yet. Be the first to add a comment!
`;
commentsContainer.appendChild(noCommentsDiv);
return;
}
// Create the comments list
const commentsList = document.createElement('div');
commentsList.className = 'comments-list mt-4';
// Add each comment
comments.forEach(comment => {
const commentElement = document.createElement('div');
commentElement.className = 'comment-item card mb-3 border-0 shadow-sm';
// Check if the current user is the comment author or an admin
const canEdit = this.isAdmin() || this.isCurrentUser(comment.username);
commentElement.innerHTML = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center">
<div class="comment-avatar bg-light rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<i class="bi bi-person-fill text-secondary"></i>
</div>
<div>
<h6 class="mb-0 fw-bold">${this.escapeHtml(comment.username)}</h6>
</div>
</div>
${canEdit ? `
<div class="dropdown">
<button class="btn btn-sm btn-light" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#editCommentModal" data-comment-id="${comment.id}" data-comment-text="${this.escapeHtml(comment.statusComment)}">
<i class="bi bi-pencil me-2"></i>Edit
</button>
</li>
<li>
<button class="dropdown-item text-danger" type="button" onclick="CommentsManager.deleteComment('${comment.id}', '${facilityId}')">
<i class="bi bi-trash me-2"></i>Delete
</button>
</li>
</ul>
</div>
` : ''}
</div>
<p class="mb-0">${this.escapeHtml(comment.statusComment)}</p>
</div>
`;
commentsList.appendChild(commentElement);
});
commentsContainer.appendChild(commentsList);
},
/**
* Deletes a comment
*/
async deleteComment(commentId, facilityId) {
// Confirm deletion
if (!confirm('Are you sure you want to delete this comment?')) {
return;
}
try {
// Use the API client to delete a status comment
const data = await window.api.deleteFacilityStatus(commentId, facilityId);
if (data.success) {
// Reload comments to reflect the deletion
this.loadFacilityComments(facilityId);
} else {
console.error('Delete comment failed:', data.error);
alert(data.error || 'Failed to delete comment');
}
} catch (error) {
console.error('Error deleting comment:', error);
alert('Failed to delete comment: ' + error.message);
}
},
/**
* Checks if the current user is an admin
*/
isAdmin() {
return window.auth && window.auth.isAdmin();
},
/**
* Checks if the given username matches the current user
*/
isCurrentUser(username) {
const user = window.auth && window.auth.getUser();
return user && user.username === username;
},
/**
* Safely escapes HTML special characters to prevent XSS attacks
*/
escapeHtml(unsafe) {
if (unsafe === null || unsafe === undefined) {
return '';
}
return unsafe
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
};
// Listen for DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
CommentsManager.state.isDomReady = true;
CommentsManager.checkinitialise();
});
} else {
CommentsManager.state.isDomReady = true;
CommentsManager.checkinitialise();
}
// Listen for auth ready
if (window.auth) {
CommentsManager.state.isAuthReady = true;
CommentsManager.checkinitialise();
} else {
window.addEventListener('authReady', () => {
console.log('auth is now ready');
CommentsManager.state.isAuthReady = true;
CommentsManager.checkinitialise();
});
// Fallback timeout in case the event doesn't fire
setTimeout(() => {
if (!CommentsManager.state.isAuthReady && window.auth) {
console.log('auth found via timeout check');
CommentsManager.state.isAuthReady = true;
CommentsManager.checkinitialise();
}
}, 1000);
}
// Export the CommentsManager to the window
window.CommentsManager = CommentsManager;

Some files were not shown because too many files have changed in this diff Show More