4 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
43 changed files with 2875 additions and 21679 deletions

6
.gitignore vendored
View File

@@ -15,3 +15,9 @@
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
# Generated Files
generate*.*
user_credentials.txt
add_facilities.py
facility_generation_log.txt

7
.idea/dataSources.xml generated
View File

@@ -36,5 +36,12 @@
<jdbc-url>jdbc:sqlite:Databases/ecobuddy.sqlite</jdbc-url> <jdbc-url>jdbc:sqlite:Databases/ecobuddy.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir> <working-dir>$ProjectFileDir$</working-dir>
</data-source> </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> </component>
</project> </project>

View File

@@ -1,232 +0,0 @@
import sqlite3
import random
# Connect to the SQLite database
conn = sqlite3.connect('Databases/ecobuddy.sqlite')
cursor = conn.cursor()
# Check if we need to add any new categories
cursor.execute("SELECT id FROM ecoCategories")
existing_categories = [row[0] for row in cursor.fetchall()]
# Add two new categories
new_categories = [
(16, "Urban Farms"),
(17, "Rainwater Harvesting Systems")
]
for category in new_categories:
if category[0] not in existing_categories:
cursor.execute("INSERT INTO ecoCategories (id, name) VALUES (?, ?)", category)
print(f"Added new category: {category[1]}")
# Get list of user IDs for contributors
cursor.execute("SELECT id FROM ecoUser")
user_ids = [row[0] for row in cursor.fetchall()]
# Define 10 new ecological facilities in the UK with accurate location data
new_facilities = [
{
"title": "Community Garden Hackney",
"category": 12, # Pollinator Gardens
"description": "Urban garden with native plants to support local pollinators",
"houseNumber": "45",
"streetName": "Dalston Lane",
"county": "Greater London",
"town": "London",
"postcode": "E8 3AH",
"lng": -0.0612,
"lat": 51.5476,
"contributor": random.choice(user_ids),
"status_comments": [
"Recently expanded with new wildflower section",
"Volunteer days every Saturday"
]
},
{
"title": "Rooftop Solar Farm",
"category": 8, # Green Roofs
"description": "Combined green roof and solar panel installation on commercial building",
"houseNumber": "120",
"streetName": "Deansgate",
"county": "Greater Manchester",
"town": "Manchester",
"postcode": "M3 2QJ",
"lng": -2.2484,
"lat": 53.4808,
"contributor": random.choice(user_ids),
"status_comments": [
"Generates power for the entire building"
]
},
{
"title": "Edinburgh Tool Library",
"category": 15, # Community Tool Libraries
"description": "Borrow tools instead of buying them - reducing waste and consumption",
"houseNumber": "25",
"streetName": "Leith Walk",
"county": "Edinburgh",
"town": "Edinburgh",
"postcode": "EH6 8LN",
"lng": -3.1752,
"lat": 55.9677,
"contributor": random.choice(user_ids),
"status_comments": []
},
{
"title": "Cardiff Bay Water Refill Station",
"category": 9, # Public Water Refill Stations
"description": "Free water refill station to reduce plastic bottle usage",
"houseNumber": "3",
"streetName": "Mermaid Quay",
"county": "Cardiff",
"town": "Cardiff",
"postcode": "CF10 5BZ",
"lng": -3.1644,
"lat": 51.4644,
"contributor": random.choice(user_ids),
"status_comments": [
"Recently cleaned and maintained",
"High usage during summer months"
]
},
{
"title": "Bristol Urban Farm",
"category": 16, # Urban Farms (new category)
"description": "Community-run urban farm providing local produce and education",
"houseNumber": "18",
"streetName": "Stapleton Road",
"county": "Bristol",
"town": "Bristol",
"postcode": "BS5 0RA",
"lng": -2.5677,
"lat": 51.4635,
"contributor": random.choice(user_ids),
"status_comments": [
"Open for volunteers Tuesday-Sunday"
]
},
{
"title": "Newcastle Rainwater Collection System",
"category": 17, # Rainwater Harvesting Systems (new category)
"description": "Public demonstration of rainwater harvesting for garden irrigation",
"houseNumber": "55",
"streetName": "Northumberland Street",
"county": "Tyne and Wear",
"town": "Newcastle upon Tyne",
"postcode": "NE1 7DH",
"lng": -1.6178,
"lat": 54.9783,
"contributor": random.choice(user_ids),
"status_comments": []
},
{
"title": "Brighton Beach Solar Bench",
"category": 7, # Solar-Powered Benches
"description": "Solar-powered bench with USB charging ports and WiFi",
"houseNumber": "",
"streetName": "Kings Road",
"county": "East Sussex",
"town": "Brighton",
"postcode": "BN1 2FN",
"lng": -0.1426,
"lat": 50.8214,
"contributor": random.choice(user_ids),
"status_comments": [
"Popular spot for tourists",
"One USB port currently not working"
]
},
{
"title": "Leeds Community Compost Hub",
"category": 6, # Community Compost Bins
"description": "Large-scale community composting facility for local residents",
"houseNumber": "78",
"streetName": "Woodhouse Lane",
"county": "West Yorkshire",
"town": "Leeds",
"postcode": "LS2 9JT",
"lng": -1.5491,
"lat": 53.8067,
"contributor": random.choice(user_ids),
"status_comments": [
"Recently expanded capacity"
]
},
{
"title": "Glasgow EV Charging Hub",
"category": 4, # Public EV Charging Stations
"description": "Multi-vehicle EV charging station with fast chargers",
"houseNumber": "42",
"streetName": "Buchanan Street",
"county": "Glasgow",
"town": "Glasgow",
"postcode": "G1 3JX",
"lng": -4.2526,
"lat": 55.8621,
"contributor": random.choice(user_ids),
"status_comments": [
"6 charging points available",
"24/7 access"
]
},
{
"title": "Oxford E-Waste Collection Center",
"category": 13, # E-Waste Collection Bins
"description": "Dedicated facility for proper disposal and recycling of electronic waste",
"houseNumber": "15",
"streetName": "St Aldate's",
"county": "Oxfordshire",
"town": "Oxford",
"postcode": "OX1 1BX",
"lng": -1.2577,
"lat": 51.7520,
"contributor": random.choice(user_ids),
"status_comments": []
}
]
# Insert facilities into the database
for facility in new_facilities:
cursor.execute("""
INSERT INTO ecoFacilities
(title, category, description, houseNumber, streetName, county, town, postcode, lng, lat, contributor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
facility["title"],
facility["category"],
facility["description"],
facility["houseNumber"],
facility["streetName"],
facility["county"],
facility["town"],
facility["postcode"],
facility["lng"],
facility["lat"],
facility["contributor"]
))
# Get the ID of the inserted facility
facility_id = cursor.lastrowid
# Add status comments if any
for comment in facility["status_comments"]:
cursor.execute("""
INSERT INTO ecoFacilityStatus (facilityId, statusComment)
VALUES (?, ?)
""", (facility_id, comment))
print(f"Added facility: {facility['title']} in {facility['town']}")
# Commit the changes and close the connection
conn.commit()
conn.close()
print("\nSuccessfully added 10 new ecological facilities to the database.")
Using the test data in the ecoCategories, ecoFacilities, and ecoFacilityStatus, please create 10 new ecological facilities with the following constraints:
Must be located in the United Kingdom (Names of areas, towns, cities, counties)
Postcode, Latitude and Longitude must be fairly accurate to the location you have generated for the area.
More categories may be made, it is up to you, but make sure foreign keys are respected.
Contributor may be of any user in the database.
Not all facilities must have statusComments, however facilities may have multiple statusComments.

Binary file not shown.

Binary file not shown.

View File

@@ -1,66 +0,0 @@
Starting facility generation at 2025-03-14 22:20:38.302675
Target: 1000 new facilities
Generating facilities...
Generated 100 facilities so far...
Generated 200 facilities so far...
Generated 300 facilities so far...
Generated 400 facilities so far...
Generated 500 facilities so far...
Generated 600 facilities so far...
Generated 700 facilities so far...
Generated 800 facilities so far...
Generated 900 facilities so far...
Generated 1000 facilities so far...
Inserting facilities into database...
Inserted batch 1/20
Inserted batch 2/20
Inserted batch 3/20
Inserted batch 4/20
Inserted batch 5/20
Inserted batch 6/20
Inserted batch 7/20
Inserted batch 8/20
Inserted batch 9/20
Inserted batch 10/20
Inserted batch 11/20
Inserted batch 12/20
Inserted batch 13/20
Inserted batch 14/20
Inserted batch 15/20
Inserted batch 16/20
Inserted batch 17/20
Inserted batch 18/20
Inserted batch 19/20
Inserted batch 20/20
Inserting status comments...
Inserted comment batch 1/23
Inserted comment batch 2/23
Inserted comment batch 3/23
Inserted comment batch 4/23
Inserted comment batch 5/23
Inserted comment batch 6/23
Inserted comment batch 7/23
Inserted comment batch 8/23
Inserted comment batch 9/23
Inserted comment batch 10/23
Inserted comment batch 11/23
Inserted comment batch 12/23
Inserted comment batch 13/23
Inserted comment batch 14/23
Inserted comment batch 15/23
Inserted comment batch 16/23
Inserted comment batch 17/23
Inserted comment batch 18/23
Inserted comment batch 19/23
Inserted comment batch 20/23
Inserted comment batch 21/23
Inserted comment batch 22/23
Inserted comment batch 23/23
Generation complete at 2025-03-14 22:20:38.336715
Total facilities in database: 1030
Total status comments in database: 1147
Generated facilities saved to generated_facilities.csv for reference

View File

@@ -1,568 +0,0 @@
import sqlite3
import random
import csv
import os
from datetime import datetime
# Connect to the SQLite database
conn = sqlite3.connect('Databases/ecobuddy.sqlite')
cursor = conn.cursor()
# Get current max facility ID
cursor.execute("SELECT MAX(id) FROM ecoFacilities")
max_facility_id = cursor.fetchone()[0] or 0
# Get list of user IDs for contributors
cursor.execute("SELECT id FROM ecoUser")
user_ids = [row[0] for row in cursor.fetchall()]
# Get list of categories
cursor.execute("SELECT id, name FROM ecoCategories")
categories = {row[0]: row[1] for row in cursor.fetchall()}
# UK Cities and Towns with their counties and approximate coordinates
uk_locations = [
# Format: Town/City, County, Latitude, Longitude, Postcode Area
("London", "Greater London", 51.5074, -0.1278, "EC"),
("Birmingham", "West Midlands", 52.4862, -1.8904, "B"),
("Manchester", "Greater Manchester", 53.4808, -2.2426, "M"),
("Glasgow", "Glasgow", 55.8642, -4.2518, "G"),
("Liverpool", "Merseyside", 53.4084, -2.9916, "L"),
("Bristol", "Bristol", 51.4545, -2.5879, "BS"),
("Edinburgh", "Edinburgh", 55.9533, -3.1883, "EH"),
("Leeds", "West Yorkshire", 53.8008, -1.5491, "LS"),
("Sheffield", "South Yorkshire", 53.3811, -1.4701, "S"),
("Newcastle upon Tyne", "Tyne and Wear", 54.9783, -1.6178, "NE"),
("Nottingham", "Nottinghamshire", 52.9548, -1.1581, "NG"),
("Cardiff", "Cardiff", 51.4816, -3.1791, "CF"),
("Belfast", "Belfast", 54.5973, -5.9301, "BT"),
("Brighton", "East Sussex", 50.8225, -0.1372, "BN"),
("Leicester", "Leicestershire", 52.6369, -1.1398, "LE"),
("Aberdeen", "Aberdeen", 57.1497, -2.0943, "AB"),
("Portsmouth", "Hampshire", 50.8198, -1.0880, "PO"),
("York", "North Yorkshire", 53.9599, -1.0873, "YO"),
("Swansea", "Swansea", 51.6214, -3.9436, "SA"),
("Oxford", "Oxfordshire", 51.7520, -1.2577, "OX"),
("Cambridge", "Cambridgeshire", 52.2053, 0.1218, "CB"),
("Exeter", "Devon", 50.7184, -3.5339, "EX"),
("Bath", "Somerset", 51.3751, -2.3617, "BA"),
("Reading", "Berkshire", 51.4543, -0.9781, "RG"),
("Preston", "Lancashire", 53.7632, -2.7031, "PR"),
("Coventry", "West Midlands", 52.4068, -1.5197, "CV"),
("Hull", "East Yorkshire", 53.7676, -0.3274, "HU"),
("Stoke-on-Trent", "Staffordshire", 53.0027, -2.1794, "ST"),
("Wolverhampton", "West Midlands", 52.5870, -2.1288, "WV"),
("Plymouth", "Devon", 50.3755, -4.1427, "PL"),
("Derby", "Derbyshire", 52.9225, -1.4746, "DE"),
("Sunderland", "Tyne and Wear", 54.9069, -1.3830, "SR"),
("Southampton", "Hampshire", 50.9097, -1.4044, "SO"),
("Norwich", "Norfolk", 52.6309, 1.2974, "NR"),
("Bournemouth", "Dorset", 50.7192, -1.8808, "BH"),
("Middlesbrough", "North Yorkshire", 54.5742, -1.2350, "TS"),
("Blackpool", "Lancashire", 53.8175, -3.0357, "FY"),
("Bolton", "Greater Manchester", 53.5785, -2.4299, "BL"),
("Ipswich", "Suffolk", 52.0567, 1.1482, "IP"),
("Telford", "Shropshire", 52.6784, -2.4453, "TF"),
("Dundee", "Dundee", 56.4620, -2.9707, "DD"),
("Peterborough", "Cambridgeshire", 52.5695, -0.2405, "PE"),
("Huddersfield", "West Yorkshire", 53.6458, -1.7850, "HD"),
("Luton", "Bedfordshire", 51.8787, -0.4200, "LU"),
("Warrington", "Cheshire", 53.3900, -2.5970, "WA"),
("Southend-on-Sea", "Essex", 51.5459, 0.7077, "SS"),
("Swindon", "Wiltshire", 51.5557, -1.7797, "SN"),
("Slough", "Berkshire", 51.5105, -0.5950, "SL"),
("Watford", "Hertfordshire", 51.6565, -0.3903, "WD"),
("Carlisle", "Cumbria", 54.8952, -2.9335, "CA")
]
# Street name components for generating realistic street names
street_prefixes = ["High", "Main", "Church", "Park", "Mill", "Station", "London", "Victoria", "Queen", "King", "North", "South", "East", "West", "New", "Old", "Castle", "Bridge", "Green", "Market", "School", "Manor", "Abbey", "Priory", "Cathedral", "University", "College", "Hospital", "Railway", "Canal", "River", "Forest", "Wood", "Hill", "Mount", "Valley", "Meadow", "Field", "Farm", "Garden", "Orchard", "Vineyard", "Grange", "Lodge", "Court", "Hall", "House", "Cottage", "Barn", "Mill", "Windmill", "Watermill", "Forge", "Quarry", "Mine", "Pit", "Well", "Spring", "Brook", "Stream", "Lake", "Pond", "Pool", "Reservoir", "Bay", "Cove", "Beach", "Cliff", "Rock", "Stone", "Granite", "Marble", "Slate", "Clay", "Sand", "Gravel", "Chalk", "Flint", "Coal", "Iron", "Steel", "Copper", "Silver", "Gold", "Tin", "Lead", "Zinc", "Brass", "Bronze", "Pewter", "Nickel", "Cobalt", "Chromium", "Titanium", "Aluminium", "Silicon", "Carbon", "Oxygen", "Hydrogen", "Nitrogen", "Helium", "Neon", "Argon", "Krypton", "Xenon", "Radon"]
street_suffixes = ["Street", "Road", "Lane", "Avenue", "Drive", "Boulevard", "Way", "Place", "Square", "Court", "Terrace", "Close", "Crescent", "Gardens", "Grove", "Mews", "Alley", "Walk", "Path", "Trail", "Hill", "Rise", "View", "Heights", "Park", "Green", "Meadow", "Field", "Common", "Heath", "Moor", "Down", "Fell", "Pike", "Tor", "Crag", "Cliff", "Ridge", "Edge", "Top", "Bottom", "Side", "End", "Corner", "Junction", "Cross", "Gate", "Bridge", "Ford", "Ferry", "Wharf", "Quay", "Dock", "Harbor", "Port", "Bay", "Cove", "Beach", "Shore", "Bank", "Strand", "Esplanade", "Parade", "Promenade", "Embankment", "Causeway", "Viaduct", "Tunnel", "Passage", "Arcade", "Gallery", "Mall", "Market", "Bazaar", "Fair", "Exchange", "Mart", "Emporium", "Center", "Circle", "Oval", "Triangle", "Pentagon", "Hexagon", "Octagon", "Circus", "Ring", "Loop", "Bend", "Curve", "Turn", "Twist", "Spiral", "Coil", "Helix", "Maze", "Labyrinth"]
# Facility descriptions by category
category_descriptions = {
1: [ # Recycling Bins
"Public recycling point for paper, glass, plastic, and metal",
"Community recycling station with separate bins for different materials",
"Recycling center with facilities for household waste separation",
"Public access recycling bins for common household recyclables",
"Multi-material recycling point with clear instructions for proper sorting"
],
2: [ # e-Scooters
"Dockless e-scooter rental station with multiple vehicles available",
"E-scooter parking and charging zone for public use",
"Designated e-scooter pickup and drop-off point",
"E-scooter sharing station with app-based rental system",
"Electric scooter hub with maintenance and charging facilities"
],
3: [ # Bike Share Stations
"Public bicycle sharing station with multiple bikes available",
"Bike rental hub with secure docking stations",
"Community bike share point with regular and electric bicycles",
"Cycle hire station with self-service rental system",
"Bike sharing facility with maintenance and repair services"
],
4: [ # Public EV Charging Stations
"Electric vehicle charging point with multiple connectors",
"Fast-charging station for electric vehicles",
"Public EV charging facility with covered waiting area",
"Multi-vehicle electric charging hub with different power options",
"EV charging station with renewable energy source"
],
5: [ # Battery Recycling Points
"Dedicated collection point for used batteries of all sizes",
"Battery recycling bin with separate compartments for different types",
"Safe disposal facility for household and small electronics batteries",
"Battery collection point with educational information about recycling",
"Secure battery recycling station to prevent environmental contamination"
],
6: [ # Community Compost Bins
"Neighborhood composting facility for food and garden waste",
"Community compost bins with educational signage",
"Public composting station with separate sections for different stages",
"Shared compost facility managed by local volunteers",
"Urban composting hub turning food waste into valuable soil"
],
7: [ # Solar-Powered Benches
"Solar bench with USB charging ports and WiFi connectivity",
"Public seating with integrated solar panels and device charging",
"Smart bench powered by solar energy with digital information display",
"Solar-powered rest area with phone charging capabilities",
"Eco-friendly bench with solar panels and LED lighting"
],
8: [ # Green Roofs
"Building with extensive green roof system visible from public areas",
"Accessible green roof garden with native plant species",
"Public building showcasing sustainable rooftop vegetation",
"Green roof installation with educational tours available",
"Biodiverse roof garden with insect habitats and rainwater collection"
],
9: [ # Public Water Refill Stations
"Free water refill station to reduce plastic bottle usage",
"Public drinking fountain with bottle filling capability",
"Water refill point with filtered water options",
"Accessible water station encouraging reusable bottles",
"Community water dispenser with usage counter display"
],
10: [ # Waste Oil Collection Points
"Cooking oil recycling point for residential use",
"Used oil collection facility with secure containers",
"Waste oil drop-off point for conversion to biodiesel",
"Community oil recycling station with spill prevention measures",
"Cooking oil collection facility with educational information"
],
11: [ # Book Swap Stations
"Community book exchange point with weatherproof shelving",
"Public book sharing library in repurposed phone box",
"Free book swap station encouraging reading and reuse",
"Neighborhood book exchange with rotating collection",
"Little free library with take-one-leave-one system"
],
12: [ # Pollinator Gardens
"Public garden designed to support bees and butterflies",
"Pollinator-friendly planting area with native flowering species",
"Community garden dedicated to supporting local insect populations",
"Bee-friendly garden with educational signage about pollinators",
"Urban wildflower meadow supporting biodiversity"
],
13: [ # E-Waste Collection Bins
"Secure collection point for electronic waste and small appliances",
"E-waste recycling bin for phones, computers, and small electronics",
"Electronic waste drop-off point with data security assurance",
"Community e-waste collection facility with regular collection schedule",
"Dedicated bin for responsible disposal of electronic items"
],
14: [ # Clothing Donation Bins
"Textile recycling point for clothes and household fabrics",
"Clothing donation bin supporting local charities",
"Secure collection point for reusable clothing and textiles",
"Community clothing recycling bin with regular collection",
"Textile donation point preventing landfill waste"
],
15: [ # Community Tool Libraries
"Tool lending library for community use and sharing",
"Shared equipment facility reducing need for individual ownership",
"Community resource center for borrowing tools and equipment",
"Tool sharing hub with membership system and workshops",
"Public tool library with wide range of equipment available"
],
16: [ # Urban Farms
"Community-run urban farm providing local produce",
"City farming project with volunteer opportunities",
"Urban agriculture site with educational programs",
"Local food growing initiative in repurposed urban space",
"Community garden with vegetable plots and fruit trees"
],
17: [ # Rainwater Harvesting Systems
"Public demonstration of rainwater collection for irrigation",
"Rainwater harvesting system with educational displays",
"Community rainwater collection facility for shared gardens",
"Visible rainwater storage and filtration system",
"Urban water conservation project with storage tanks"
]
}
# Status comments by category
status_comments = {
1: [ # Recycling Bins
"Recently emptied and cleaned",
"Some bins are nearly full",
"All bins in good condition",
"Paper bin is currently full",
"New signage installed to improve sorting"
],
2: [ # e-Scooters
"All scooters fully charged",
"Three scooters currently available",
"Maintenance scheduled for next week",
"New scooters added to this location",
"High usage area, scooters frequently unavailable"
],
3: [ # Bike Share Stations
"All docking stations operational",
"Five bikes currently available",
"Some bikes need maintenance",
"New electric bikes added",
"Popular station with high turnover"
],
4: [ # Public EV Charging Stations
"All charging points operational",
"Fast charger currently under repair",
"Peak usage during business hours",
"New charging point added last month",
"Payment system recently upgraded"
],
5: [ # Battery Recycling Points
"Collection bin recently emptied",
"Secure container in good condition",
"New signage explaining battery types",
"High usage from local businesses",
"Additional capacity added"
],
6: [ # Community Compost Bins
"Compost ready for collection",
"Needs more brown material",
"Recently turned and aerated",
"New bins added to increase capacity",
"Volunteer day scheduled for maintenance"
],
7: [ # Solar-Powered Benches
"All charging ports working",
"Solar panels recently cleaned",
"WiFi currently unavailable",
"LED lights need replacement",
"High usage during lunch hours"
],
8: [ # Green Roofs
"Plants thriving after recent rain",
"Maintenance scheduled next month",
"New species added to increase biodiversity",
"Irrigation system working well",
"Open for public tours on weekends"
],
9: [ # Public Water Refill Stations
"Water quality tested weekly",
"Fountain cleaned daily",
"Bottle filler counter shows high usage",
"New filter installed recently",
"Popular during summer months"
],
10: [ # Waste Oil Collection Points
"Container recently emptied",
"Secure lid in good condition",
"New funnel system installed",
"Collection schedule posted",
"Area kept clean and tidy"
],
11: [ # Book Swap Stations
"Good selection currently available",
"Children's books needed",
"Recently reorganized by volunteers",
"Weatherproof cover working well",
"High turnover of popular titles"
],
12: [ # Pollinator Gardens
"Plants in full bloom",
"Many bees and butterflies observed",
"New native species planted",
"Volunteer day for maintenance scheduled",
"Educational tours available"
],
13: [ # E-Waste Collection Bins
"Bin recently emptied",
"Secure deposit system working",
"Collection schedule posted",
"New items accepted now include small appliances",
"Data destruction guaranteed"
],
14: [ # Clothing Donation Bins
"Bin recently emptied",
"Clean and well-maintained",
"High quality donations appreciated",
"Winter clothing especially needed",
"Please bag items before donating"
],
15: [ # Community Tool Libraries
"New inventory system implemented",
"Popular tools often unavailable on weekends",
"Tool maintenance workshop scheduled",
"New donations recently added to collection",
"Extended hours during summer"
],
16: [ # Urban Farms
"Seasonal produce currently available",
"Volunteer opportunities posted",
"Educational workshops on weekends",
"New growing area being developed",
"Composting system recently expanded"
],
17: [ # Rainwater Harvesting Systems
"System working efficiently after recent rainfall",
"Water quality monitoring in place",
"Educational tours available by appointment",
"System capacity recently expanded",
"Used for irrigation of nearby community garden"
]
}
# Generate a realistic UK postcode based on area code
def generate_postcode(area_code):
# Format: Area + District + Space + Sector + Unit
# e.g., M1 1AA or SW1A 1AA
district = random.randint(1, 99)
sector = random.randint(1, 9)
unit = ''.join(random.choices('ABCDEFGHJKLMNPQRSTUVWXYZ', k=2)) # Excluding I and O as they're not used
if len(area_code) == 1:
return f"{area_code}{district} {sector}{unit}"
else:
return f"{area_code}{district} {sector}{unit}"
# Generate a realistic street name
def generate_street_name():
prefix = random.choice(street_prefixes)
suffix = random.choice(street_suffixes)
return f"{prefix} {suffix}"
# Generate a realistic house number
def generate_house_number():
# 80% chance of a simple number, 20% chance of a letter suffix or unit
if random.random() < 0.8:
return str(random.randint(1, 200))
else:
options = [
f"{random.randint(1, 200)}{random.choice('ABCDEFG')}", # e.g., 42A
f"Unit {random.randint(1, 20)}",
f"Flat {random.randint(1, 50)}",
f"Suite {random.randint(1, 10)}"
]
return random.choice(options)
# Add small random variation to coordinates to avoid facilities at exact same location
def vary_coordinates(lat, lng):
# Add variation of up to ~500 meters
lat_variation = random.uniform(-0.004, 0.004)
lng_variation = random.uniform(-0.006, 0.006)
return lat + lat_variation, lng + lng_variation
# Generate facility title based on category and location
def generate_title(category_name, location_name, street_name):
templates = [
f"{location_name} {category_name}",
f"{category_name} at {street_name}",
f"{street_name} {category_name}",
f"Community {category_name} {location_name}",
f"{location_name} Central {category_name}",
f"{location_name} {street_name} {category_name}"
]
return random.choice(templates)
# Create a log file to track progress
log_file = open("facility_generation_log.txt", "w")
log_file.write(f"Starting facility generation at {datetime.now()}\n")
log_file.write(f"Target: 1000 new facilities\n\n")
# Create a CSV file to store all generated facilities for reference
csv_file = open("generated_facilities.csv", "w", newline='')
csv_writer = csv.writer(csv_file)
csv_writer.writerow(["ID", "Title", "Category", "Description", "Address", "Postcode", "Latitude", "Longitude", "Contributor"])
# Prepare for batch insertion to improve performance
facilities_to_insert = []
status_comments_to_insert = []
# Track unique titles to avoid duplicates
existing_titles = set()
cursor.execute("SELECT title FROM ecoFacilities")
for row in cursor.fetchall():
existing_titles.add(row[0])
# Generate 1000 facilities
num_facilities = 1000
facilities_created = 0
log_file.write("Generating facilities...\n")
while facilities_created < num_facilities:
# Select a random location
location = random.choice(uk_locations)
location_name, county, base_lat, base_lng, postcode_area = location
# Generate 5-25 facilities per location to create clusters
facilities_per_location = min(random.randint(5, 25), num_facilities - facilities_created)
for _ in range(facilities_per_location):
# Select a random category
category_id = random.choice(list(categories.keys()))
category_name = categories[category_id]
# Generate address components
street_name = generate_street_name()
house_number = generate_house_number()
lat, lng = vary_coordinates(base_lat, base_lng)
postcode = generate_postcode(postcode_area)
# Generate title
title_base = generate_title(category_name, location_name, street_name)
title = title_base
# Ensure title is unique by adding a suffix if needed
suffix = 2
while title in existing_titles:
title = f"{title_base} {suffix}"
suffix += 1
existing_titles.add(title)
# Select description
description = random.choice(category_descriptions[category_id])
# Select contributor
contributor_id = random.choice(user_ids)
# Add to batch for insertion
facilities_to_insert.append((
title,
category_id,
description,
house_number,
street_name,
county,
location_name,
postcode,
lng,
lat,
contributor_id
))
# Log progress periodically
facilities_created += 1
if facilities_created % 100 == 0:
log_message = f"Generated {facilities_created} facilities so far..."
print(log_message)
log_file.write(log_message + "\n")
if facilities_created >= num_facilities:
break
# Insert facilities in batches for better performance
log_file.write("\nInserting facilities into database...\n")
print("Inserting facilities into database...")
batch_size = 50
for i in range(0, len(facilities_to_insert), batch_size):
batch = facilities_to_insert[i:i+batch_size]
cursor.executemany("""
INSERT INTO ecoFacilities
(title, category, description, houseNumber, streetName, county, town, postcode, lng, lat, contributor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", batch)
# Get the IDs of the inserted facilities
cursor.execute("SELECT last_insert_rowid()")
last_id = cursor.fetchone()[0]
first_id_in_batch = last_id - len(batch) + 1
# Generate status comments for each facility
for j, facility in enumerate(batch):
facility_id = first_id_in_batch + j
category_id = facility[1] # Category ID is the second element
# Write to CSV for reference
csv_writer.writerow([
facility_id,
facility[0], # title
categories[category_id], # category name
facility[2], # description
f"{facility[3]} {facility[4]}, {facility[6]}, {facility[5]}", # address
facility[7], # postcode
facility[9], # lat
facility[8], # lng
facility[10] # contributor
])
# Decide how many status comments to add (0-3)
num_comments = random.choices([0, 1, 2, 3], weights=[30, 40, 20, 10])[0]
if num_comments > 0:
# Get relevant comments for this category
relevant_comments = status_comments.get(category_id, status_comments[1]) # Default to recycling bin comments
# Select random comments without repetition
selected_comments = random.sample(relevant_comments, min(num_comments, len(relevant_comments)))
# Add to batch for insertion
for comment in selected_comments:
status_comments_to_insert.append((facility_id, comment))
# Commit after each batch
conn.commit()
log_message = f"Inserted batch {i//batch_size + 1}/{(len(facilities_to_insert)-1)//batch_size + 1}"
print(log_message)
log_file.write(log_message + "\n")
# Insert status comments in batches
if status_comments_to_insert:
log_file.write("\nInserting status comments...\n")
print("Inserting status comments...")
for i in range(0, len(status_comments_to_insert), batch_size):
batch = status_comments_to_insert[i:i+batch_size]
cursor.executemany("""
INSERT INTO ecoFacilityStatus (facilityId, statusComment)
VALUES (?, ?)
""", batch)
conn.commit()
log_message = f"Inserted comment batch {i//batch_size + 1}/{(len(status_comments_to_insert)-1)//batch_size + 1}"
print(log_message)
log_file.write(log_message + "\n")
# Get final counts
cursor.execute("SELECT COUNT(*) FROM ecoFacilities")
total_facilities = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM ecoFacilityStatus")
total_comments = cursor.fetchone()[0]
# Log completion
completion_message = f"\nGeneration complete at {datetime.now()}"
print(completion_message)
log_file.write(completion_message + "\n")
summary = f"Total facilities in database: {total_facilities}\n"
summary += f"Total status comments in database: {total_comments}\n"
summary += f"Generated facilities saved to generated_facilities.csv for reference"
print(summary)
log_file.write(summary)
# Close connections
log_file.close()
csv_file.close()
conn.commit()
conn.close()
print("\nSuccessfully added 1000 new ecological facilities to the database.")
print("A detailed log and CSV export have been created for reference.")

View File

@@ -1,79 +0,0 @@
<?php
// Connect to the SQLite database
$db = new PDO('sqlite:Databases/ecobuddy.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// List of real first names for usernames
$firstNames = [
'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Charles',
'Christopher', 'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Paul', 'Andrew', 'Joshua',
'Kenneth', 'Kevin', 'Brian', 'George', 'Timothy', 'Ronald', 'Edward', 'Jason', 'Jeffrey', 'Ryan',
'Jacob', 'Gary', 'Nicholas', 'Eric', 'Jonathan', 'Stephen', 'Larry', 'Justin', 'Scott', 'Brandon',
'Benjamin', 'Samuel', 'Gregory', 'Alexander', 'Frank', 'Patrick', 'Raymond', 'Jack', 'Dennis', 'Jerry',
'Tyler', 'Aaron', 'Jose', 'Adam', 'Nathan', 'Henry', 'Douglas', 'Zachary', 'Peter', 'Kyle',
'Ethan', 'Walter', 'Noah', 'Jeremy', 'Christian', 'Keith', 'Roger', 'Terry', 'Gerald', 'Harold',
'Sean', 'Austin', 'Carl', 'Arthur', 'Lawrence', 'Dylan', 'Jesse', 'Jordan', 'Bryan', 'Billy',
'Joe', 'Bruce', 'Gabriel', 'Logan', 'Albert', 'Willie', 'Alan', 'Juan', 'Wayne', 'Elijah',
'Randy', 'Roy', 'Vincent', 'Ralph', 'Eugene', 'Russell', 'Bobby', 'Mason', 'Philip', 'Louis'
];
// List of common words for password generation
$words = [
'Apple', 'Banana', 'Cherry', 'Dragon', 'Eagle', 'Forest', 'Garden', 'Harbor', 'Island', 'Jungle',
'Kingdom', 'Lemon', 'Mountain', 'Nature', 'Ocean', 'Planet', 'Queen', 'River', 'Summer', 'Tiger',
'Universe', 'Volcano', 'Winter', 'Yellow', 'Zebra', 'Castle', 'Diamond', 'Emerald', 'Flower', 'Galaxy',
'Horizon', 'Iceberg', 'Journey', 'Knight', 'Legend', 'Meadow', 'Nebula', 'Oasis', 'Palace', 'Quasar',
'Rainbow', 'Sapphire', 'Thunder', 'Unicorn', 'Victory', 'Whisper', 'Xylophone', 'Yacht', 'Zephyr', 'Autumn',
'Breeze', 'Cascade', 'Dolphin', 'Eclipse', 'Falcon', 'Glacier', 'Harmony', 'Infinity', 'Jasmine', 'Kaleidoscope',
'Lighthouse', 'Mirage', 'Nightfall', 'Orchard', 'Phoenix', 'Quicksilver', 'Radiance', 'Serenity', 'Twilight', 'Umbrella'
];
// Check if we already have users with these names
$existingUsers = [];
$stmt = $db->query("SELECT username FROM ecoUser");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$existingUsers[] = $row['username'];
}
// Prepare the SQL statement for inserting users
$insertStmt = $db->prepare("INSERT INTO ecoUser (username, password, userType) VALUES (?, ?, ?)");
// Create a file to store usernames and passwords
$file = fopen("user_credentials.txt", "w");
fwrite($file, "Username,Password\n");
// Generate 50 users
$usersAdded = 0;
$usedNames = [];
shuffle($firstNames);
foreach ($firstNames as $name) {
if ($usersAdded >= 50) break;
// Skip if the name is already in the database
if (in_array($name, $existingUsers) || in_array($name, $usedNames)) {
continue;
}
// Generate password in format (Word)(Word)(Word)(Digit)
$password = $words[array_rand($words)] . $words[array_rand($words)] . $words[array_rand($words)] . rand(0, 9);
// Hash the password using SHA-256
$hashedPassword = hash('sha256', $password);
// Insert the user into the database (userType 2 is for standard users)
$insertStmt->execute([$name, $hashedPassword, 2]);
// Write the credentials to the file
fwrite($file, "$name,$password\n");
$usedNames[] = $name;
$usersAdded++;
echo "Added user: $name\n";
}
fclose($file);
echo "\nSuccessfully added $usersAdded users to the database.\n";
echo "Usernames and passwords have been saved to user_credentials.txt\n";

View File

@@ -1,79 +0,0 @@
import sqlite3
import random
import hashlib
import os
# Connect to the SQLite database
conn = sqlite3.connect('Databases/ecobuddy.sqlite')
cursor = conn.cursor()
# List of real first names for usernames
first_names = [
'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Charles',
'Christopher', 'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Paul', 'Andrew', 'Joshua',
'Kenneth', 'Kevin', 'Brian', 'George', 'Timothy', 'Ronald', 'Edward', 'Jason', 'Jeffrey', 'Ryan',
'Jacob', 'Gary', 'Nicholas', 'Eric', 'Jonathan', 'Stephen', 'Larry', 'Justin', 'Scott', 'Brandon',
'Benjamin', 'Samuel', 'Gregory', 'Alexander', 'Frank', 'Patrick', 'Raymond', 'Jack', 'Dennis', 'Jerry',
'Tyler', 'Aaron', 'Jose', 'Adam', 'Nathan', 'Henry', 'Douglas', 'Zachary', 'Peter', 'Kyle',
'Ethan', 'Walter', 'Noah', 'Jeremy', 'Christian', 'Keith', 'Roger', 'Terry', 'Gerald', 'Harold',
'Sean', 'Austin', 'Carl', 'Arthur', 'Lawrence', 'Dylan', 'Jesse', 'Jordan', 'Bryan', 'Billy',
'Joe', 'Bruce', 'Gabriel', 'Logan', 'Albert', 'Willie', 'Alan', 'Juan', 'Wayne', 'Elijah',
'Randy', 'Roy', 'Vincent', 'Ralph', 'Eugene', 'Russell', 'Bobby', 'Mason', 'Philip', 'Louis'
]
# List of common words for password generation
words = [
'Apple', 'Banana', 'Cherry', 'Dragon', 'Eagle', 'Forest', 'Garden', 'Harbor', 'Island', 'Jungle',
'Kingdom', 'Lemon', 'Mountain', 'Nature', 'Ocean', 'Planet', 'Queen', 'River', 'Summer', 'Tiger',
'Universe', 'Volcano', 'Winter', 'Yellow', 'Zebra', 'Castle', 'Diamond', 'Emerald', 'Flower', 'Galaxy',
'Horizon', 'Iceberg', 'Journey', 'Knight', 'Legend', 'Meadow', 'Nebula', 'Oasis', 'Palace', 'Quasar',
'Rainbow', 'Sapphire', 'Thunder', 'Unicorn', 'Victory', 'Whisper', 'Xylophone', 'Yacht', 'Zephyr', 'Autumn',
'Breeze', 'Cascade', 'Dolphin', 'Eclipse', 'Falcon', 'Glacier', 'Harmony', 'Infinity', 'Jasmine', 'Kaleidoscope',
'Lighthouse', 'Mirage', 'Nightfall', 'Orchard', 'Phoenix', 'Quicksilver', 'Radiance', 'Serenity', 'Twilight', 'Umbrella'
]
# Check if we already have users with these names
cursor.execute("SELECT username FROM ecoUser")
existing_users = [row[0] for row in cursor.fetchall()]
# Create a file to store usernames and passwords
with open("user_credentials.txt", "w") as file:
file.write("Username,Password\n")
# Generate 50 users
users_added = 0
used_names = []
random.shuffle(first_names)
for name in first_names:
if users_added >= 50:
break
# Skip if the name is already in the database
if name in existing_users or name in used_names:
continue
# Generate password in format (Word)(Word)(Word)(Digit)
password = random.choice(words) + random.choice(words) + random.choice(words) + str(random.randint(0, 9))
# Hash the password using SHA-256
hashed_password = hashlib.sha256(password.encode()).hexdigest()
# Insert the user into the database (userType 2 is for standard users)
cursor.execute("INSERT INTO ecoUser (username, password, userType) VALUES (?, ?, ?)",
(name, hashed_password, 2))
# Write the credentials to the file
file.write(f"{name},{password}\n")
used_names.append(name)
users_added += 1
print(f"Added user: {name}")
# Commit the changes and close the connection
conn.commit()
conn.close()
print(f"\nSuccessfully added {users_added} users to the database.")
print("Usernames and passwords have been saved to user_credentials.txt")

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
Username,Password
Dylan,MirageAutumnUmbrella7
Lawrence,MirageVictoryKingdom5
Bryan,EmeraldOrchardLegend3
Alan,ThunderOceanDiamond2
Frank,XylophoneVictoryHarmony3
Logan,NatureSerenityXylophone5
Jerry,LighthousePhoenixYellow8
Harold,IcebergGlacierTiger8
Keith,YachtJourneyGarden4
Arthur,VictoryOrchardFlower0
Louis,FlowerNebulaNature3
Paul,LegendLegendYacht4
Aaron,RiverCascadeApple3
George,MeadowBreezePalace4
Carl,RadianceMirageQuasar2
Kenneth,IcebergPlanetHorizon8
Daniel,BreezeXylophoneGalaxy5
Ronald,PhoenixThunderZephyr2
Benjamin,NatureVolcanoNebula8
Joe,UnicornSapphireHorizon1
Larry,IslandZebraApple6
Zachary,SapphireUnicornJasmine0
Willie,UmbrellaVictoryHorizon8
Anthony,QueenZebraNebula9
Michael,TigerMountainNightfall8
Vincent,InfinityHorizonQuicksilver8
Roger,WinterXylophonePalace8
Kyle,IcebergDolphinDragon9
Henry,HarmonyKnightPalace7
Eugene,JourneyThunderPalace0
Billy,ThunderThunderQuicksilver9
Peter,OrchardJasmineVictory4
Christopher,QueenThunderAutumn5
Adam,SummerUnicornThunder7
Nathan,SerenityOrchardThunder2
Edward,MeadowGalaxyYellow6
Eric,DolphinNebulaYacht8
Brian,CastleFlowerGlacier4
Alexander,ForestSapphireZebra4
Andrew,GalaxyLemonApple0
Brandon,HarmonyTigerHarmony2
Russell,CherryZebraQuicksilver8
Jack,OrchardZephyrSapphire8
Jose,BananaJungleSerenity8
Jacob,KaleidoscopeEmeraldJasmine5
Dennis,KnightFlowerRainbow2
Donald,WhisperQuicksilverCastle1
William,ApplePalaceSummer6
Patrick,CastleInfinityPhoenix9
Timothy,YellowEagleSummer0

View File

@@ -1,112 +0,0 @@
<?php
/**
* Example controller showing how to use the simplified authentication
*
* This file demonstrates how to use the User::checkAuth() and User::checkAdmin()
* methods to protect routes without using middleware.
*/
require_once('Models/User.php');
/**
* Example of a protected endpoint that requires authentication
*/
function protectedEndpoint() {
// Check if user is authenticated
$auth = User::checkAuth();
if (!$auth) {
// The checkAuth method already sent the error response
return;
}
// User is authenticated, proceed with the endpoint logic
$response = [
'status' => 'success',
'message' => 'You are authenticated',
'user' => [
'id' => $auth['uid'],
'username' => $auth['username']
]
];
// Send response
header('Content-Type: application/json');
echo json_encode($response);
}
/**
* Example of an admin-only endpoint
*/
function adminEndpoint() {
// Check if user is an admin
$auth = User::checkAdmin();
if (!$auth) {
// The checkAdmin method already sent the error response
return;
}
// User is an admin, proceed with the admin-only logic
$response = [
'status' => 'success',
'message' => 'You have admin access',
'user' => [
'id' => $auth['uid'],
'username' => $auth['username']
]
];
// Send response
header('Content-Type: application/json');
echo json_encode($response);
}
/**
* Example of a public endpoint that doesn't require authentication
* but can still use authentication data if available
*/
function publicEndpoint() {
// Check if user is authenticated, but don't require it
$auth = User::checkAuth(false);
$response = [
'status' => 'success',
'message' => 'This is a public endpoint'
];
// Add user info if authenticated
if ($auth) {
$response['user'] = [
'id' => $auth['uid'],
'username' => $auth['username']
];
} else {
$response['user'] = 'Guest';
}
// Send response
header('Content-Type: application/json');
echo json_encode($response);
}
/**
* Example of how to use these functions in a simple router
*/
function handleRequest() {
$route = $_GET['route'] ?? 'public';
switch ($route) {
case 'protected':
protectedEndpoint();
break;
case 'admin':
adminEndpoint();
break;
case 'public':
default:
publicEndpoint();
break;
}
}
// Call the router function
handleRequest();

View File

@@ -2,7 +2,9 @@
require_once('UserDataSet.php'); require_once('UserDataSet.php');
/** /**
* Authentication service for handling JWT-based authentication * Backend Authentication service for handling JWT authentication
* https://jwt.io/introduction
* This cost me blood, sweat and tears, mostly tears.
*/ */
class AuthService { class AuthService {
private string $secretKey; private string $secretKey;
@@ -14,7 +16,7 @@ class AuthService {
* @throws Exception if OpenSSL extension is not loaded * @throws Exception if OpenSSL extension is not loaded
*/ */
public function __construct() { public function __construct() {
// Load environment variables from .env file // Load environment variables from .env file (:D more configuration needs to be added to .env, but scope creep already huge)
$envFile = __DIR__ . '/../.env'; $envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) { if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
@@ -37,14 +39,14 @@ class AuthService {
$this->secretKey = getenv('JWT_SECRET_KEY') ?: 'your-256-bit-secret'; $this->secretKey = getenv('JWT_SECRET_KEY') ?: 'your-256-bit-secret';
$this->tokenExpiry = (int)(getenv('JWT_TOKEN_EXPIRY') ?: 3600); $this->tokenExpiry = (int)(getenv('JWT_TOKEN_EXPIRY') ?: 3600);
// Verify OpenSSL extension is available // Verify OpenSSL extension is available. This should be on by default regardless, but just in case.
if (!extension_loaded('openssl')) { if (!extension_loaded('openssl')) {
throw new Exception('OpenSSL extension is required for JWT'); throw new Exception('OpenSSL extension is required for JWT');
} }
} }
/** /**
* Generates a JWT token for a user * Generates a JWT token
* @param array $userData User information to include in token * @param array $userData User information to include in token
* @return string The generated JWT token * @return string The generated JWT token
*/ */
@@ -52,6 +54,7 @@ class AuthService {
$issuedAt = time(); $issuedAt = time();
$expire = $issuedAt + $this->tokenExpiry; $expire = $issuedAt + $this->tokenExpiry;
// Create payload with user data
$payload = [ $payload = [
'iat' => $issuedAt, 'iat' => $issuedAt,
'exp' => $expire, 'exp' => $expire,
@@ -101,7 +104,7 @@ class AuthService {
$signature = hash_hmac('sha256', "$header.$payload", $this->secretKey, true); $signature = hash_hmac('sha256', "$header.$payload", $this->secretKey, true);
$signature = $this->base64UrlEncode($signature); $signature = $this->base64UrlEncode($signature);
return "$header.$payload.$signature"; return "$header.$payload.$signature"; //Wooooooo!!! JWT is a thing!
} }
/** /**

View File

@@ -1,15 +1,11 @@
<?php <?php
/** /**
* Represents a facility in the EcoBuddy system * Represents a singular facility
* *
* This class serves as a data model for facilities, encapsulating all * Data model for facilities, encapsulating all
* the properties and behaviours of a single facility. It follows the * properties and behaviours of a single facility.
* Data Transfer Object (DTO) pattern that I learned about in my
* software architecture module.
* *
* Each facility has location data, descriptive information, and metadata * Each facility has location data, descriptive info, and metadata.
* about who contributed it. This class provides a clean interface for
* accessing this data throughout the application.
*/ */
class FacilityData { class FacilityData {
/** /**
@@ -77,10 +73,6 @@ class FacilityData {
/** /**
* Gets the facility's title * Gets the facility's title
*
* The title is the primary name or label for the facility that
* is displayed to users in the interface.
*
* @return string The facility title * @return string The facility title
*/ */
public function getTitle() { public function getTitle() {
@@ -89,10 +81,6 @@ class FacilityData {
/** /**
* Gets the facility's category * Gets the facility's category
*
* The category helps classify facilities by type, such as
* recycling centre, community garden, etc.
*
* @return string The facility category * @return string The facility category
*/ */
public function getCategory() { public function getCategory() {
@@ -101,10 +89,6 @@ class FacilityData {
/** /**
* Gets the facility's current status * Gets the facility's current status
*
* The status indicates whether the facility is operational,
* under maintenance, closed, etc.
*
* @return string The facility status * @return string The facility status
*/ */
public function getStatus() { public function getStatus() {
@@ -113,10 +97,6 @@ class FacilityData {
/** /**
* Gets the facility's description * Gets the facility's description
*
* The description provides detailed information about the facility,
* its purpose, services offered, etc.
*
* @return string The facility description * @return string The facility description
*/ */
public function getDescription() { public function getDescription() {
@@ -125,9 +105,6 @@ class FacilityData {
/** /**
* Gets the facility's house/building number * Gets the facility's house/building number
*
* This is part of the facility's address and helps locate it physically.
*
* @return string The house/building number * @return string The house/building number
*/ */
public function getHouseNumber() { public function getHouseNumber() {
@@ -136,9 +113,6 @@ class FacilityData {
/** /**
* Gets the facility's street name * Gets the facility's street name
*
* This is part of the facility's address and helps locate it physically.
*
* @return string The street name * @return string The street name
*/ */
public function getStreetName() { public function getStreetName() {
@@ -147,9 +121,6 @@ class FacilityData {
/** /**
* Gets the facility's county * Gets the facility's county
*
* This is part of the facility's address and helps locate it physically.
*
* @return string The county * @return string The county
*/ */
public function getCounty() { public function getCounty() {
@@ -158,9 +129,6 @@ class FacilityData {
/** /**
* Gets the facility's town or city * Gets the facility's town or city
*
* This is part of the facility's address and helps locate it physically.
*
* @return string The town or city * @return string The town or city
*/ */
public function getTown() { public function getTown() {
@@ -169,10 +137,6 @@ class FacilityData {
/** /**
* Gets the facility's postcode * Gets the facility's postcode
*
* This is part of the facility's address and helps locate it physically.
* It's also useful for searching facilities by location.
*
* @return string The postcode * @return string The postcode
*/ */
public function getPostcode() { public function getPostcode() {
@@ -181,10 +145,6 @@ class FacilityData {
/** /**
* Gets the facility's longitude coordinate * Gets the facility's longitude coordinate
*
* This is used for displaying the facility on a map and
* for calculating distances between facilities.
*
* @return float The longitude coordinate * @return float The longitude coordinate
*/ */
public function getLng() { public function getLng() {
@@ -193,10 +153,6 @@ class FacilityData {
/** /**
* Gets the facility's latitude coordinate * Gets the facility's latitude coordinate
*
* This is used for displaying the facility on a map and
* for calculating distances between facilities.
*
* @return float The latitude coordinate * @return float The latitude coordinate
*/ */
public function getLat() { public function getLat() {
@@ -205,10 +161,6 @@ class FacilityData {
/** /**
* Gets the username of the facility's contributor * Gets the username of the facility's contributor
*
* This tracks who added the facility to the system,
* which is useful for auditing and attribution.
*
* @return string The contributor's username * @return string The contributor's username
*/ */
public function getContributor() { public function getContributor() {

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]);
}
}

View File

@@ -45,7 +45,6 @@ class User {
* *
* Checks for a JWT token in the Authorization header and validates it. * Checks for a JWT token in the Authorization header and validates it.
* If valid, sets user properties based on the token payload. * If valid, sets user properties based on the token payload.
* Also starts a session if needed for CAPTCHA verification during registration.
*/ */
public function __construct() { public function __construct() {
// Initialise default values // Initialise default values
@@ -69,21 +68,6 @@ class User {
$this->_loggedIn = true; $this->_loggedIn = true;
} }
} }
// Start session only if needed for CAPTCHA
if (session_status() === PHP_SESSION_NONE && isset($_GET['page']) && $_GET['page'] === 'register') {
session_start();
}
}
/**
* Sets the user's access level
*
* @param int $level The access level to set (admin = 1, regular user = 2)
* @return void
*/
private function setAccessLevel($level) {
$this->_accessLevel = $level;
} }
/** /**
@@ -135,33 +119,6 @@ class User {
} }
} }
/**
* Logs the user out
*
* Resets all user properties to their default values.
* Note: This doesn't invalidate the JWT token - handled client-side
* by removing the token from storage.
*
* @return void
*/
public function logout() {
// Reset user properties
$this->_loggedIn = false;
$this->_username = "None";
$this->_userId = "0";
$this->_accessLevel = null;
}
/**
* Checks if the user is currently logged in
*
* @return bool True if the user is logged in, false otherwise
*/
public function isLoggedIn(): bool
{
return $this->_loggedIn;
}
/** /**
* Static method to check if a request is authenticated * Static method to check if a request is authenticated
* *
@@ -207,7 +164,7 @@ class User {
{ {
$payload = self::checkAuth(true); $payload = self::checkAuth(true);
if ($payload && isset($payload['accessLevel']) && $payload['accessLevel'] == 1) { if ($payload && isset($payload['accessLevel']) && ($payload['accessLevel'] == 1 || $payload['accessLevel'] == 0)) {
return $payload; return $payload;
} }

View File

@@ -21,18 +21,25 @@ require('template/header.phtml')
<div class="card-header bg-light py-3"> <div class="card-header bg-light py-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<h5 class="mb-0 fw-bold text-primary"> <!-- Search and filter controls -->
<i class="bi bi-geo-alt-fill me-2 text-success"></i>Facilities <div class="d-flex flex-column flex-lg-row search-controls mx-auto">
</h5> <form class="d-flex flex-column flex-lg-row gap-2 w-100" role="search" action="" method="POST">
<!-- Badge showing the number of facilities --> <div class="input-group flex-grow-1">
<span class="badge bg-success rounded-pill ms-2" id="facilityCount"></span> <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> </div>
<?php if($view->user->getAccessLevel() == 1): ?> </form>
</div>
</div>
<!-- Admin-only buttons -->
<div id="adminButtons" style="display: none;">
<!-- Add new facility button (admin only) --> <!-- Add new facility button (admin only) -->
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#createModal"> <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 <i class="bi bi-plus-circle me-1"></i>Add New Facility
</button> </button>
<?php endif; ?> </div>
</div> </div>
</div> </div>
<!-- Pagination controls --> <!-- Pagination controls -->
@@ -44,20 +51,8 @@ require('template/header.phtml')
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="facilityTable"> <table class="table table-hover align-middle mb-0" id="facilityTable">
<thead class="table-light"> <thead class="table-light">
<tr> <tr id="tableHeaderRow">
<?php if($view->user->getAccessLevel() == 1): ?> <!-- Table headers will be dynamically populated by JavaScript -->
<th class="fw-semibold" style="width: 40px;">ID</th>
<?php else: ?>
<th class="d-none">ID</th>
<?php endif; ?>
<th class="fw-semibold" style="width: 15%;">Title</th>
<th class="fw-semibold text-center" style="width: 10%;">Category</th>
<th class="fw-semibold" style="width: 25%;">Description</th>
<th class="fw-semibold" style="width: 20%;">Address</th>
<th class="fw-semibold text-center" style="width: 8%;" hidden>Postcode</th>
<th class="fw-semibold text-center" style="width: 12%;">Coordinates</th>
<th class="fw-semibold text-center" style="width: 8%;">Contributor</th>
<th class="fw-semibold text-center" style="width: 10%;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="border-top-0"> <tbody class="border-top-0">
@@ -76,29 +71,51 @@ require('template/header.phtml')
<?php require('template/deleteModal.phtml') ?> <?php require('template/deleteModal.phtml') ?>
<?php require('template/statusModal.phtml') ?> <?php require('template/statusModal.phtml') ?>
<!-- Script to update the facility count badge --> <!-- Regular user view (no admin buttons) -->
<div id="regularUserView"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { // Function to update UI based on user role
// Update facility count badge based on data in sessionStorage async function updateRoleBasedUI() {
const updateFacilityCount = () => { const adminButtons = document.getElementById('adminButtons');
const facilityData = JSON.parse(sessionStorage.getItem('facilityData') || '[]'); const regularUserView = document.getElementById('regularUserView');
const countBadge = document.getElementById('facilityCount'); const tableHeaderRow = document.getElementById('tableHeaderRow');
if (countBadge) {
countBadge.textContent = `${facilityData.length} facilities`; // 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;
}
} }
};
// Initial count update when the page loads // Show/hide admin buttons
updateFacilityCount(); if (adminButtons) {
adminButtons.style.display = isAdmin ? 'block' : 'none';
}
// Listen for changes in facility data to update the count 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) { window.addEventListener('storage', function(e) {
if (e.key === 'facilityData') { if (e.key === 'token' || e.key === 'user') {
updateFacilityCount(); updateRoleBasedUI();
} }
}); });
});
</script> </script>
<?php require('template/footer.phtml');?> <?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') ?>

View File

@@ -1,4 +1,3 @@
<?php if($view->user->getAccessLevel() == 1): ?>
<!-- Create Facility Modal --> <!-- Create Facility Modal -->
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true"> <div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -10,136 +9,111 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body p-4"> <div class="modal-body p-4">
<!-- Create facility form -->
<form id="createForm"> <form id="createForm">
<input type="hidden" name="action" value="create"> <!-- Form fields -->
<div class="mb-3"> <div class="mb-3">
<label for="titlCreate" class="form-label">Title</label> <label for="createTitle" class="form-label">Facility Name</label>
<div class="input-group"> <input type="text" class="form-control" id="createTitle" name="title" required>
<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="titlCreate" name="titlCreate" placeholder="Enter facility title" required>
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="cateCreate" class="form-label">Category</label> <label for="createCategory" class="form-label">Category</label>
<div class="input-group"> <select class="form-select" id="createCategory" name="category" required>
<span class="input-group-text bg-light border-end-0"> <option value="" selected disabled>Select a category</option>
<i class="bi bi-bookmark text-success"></i> <option value="recycling">Recycling Center</option>
</span> <option value="compost">Composting Facility</option>
<input type="text" class="form-control border-start-0" id="cateCreate" name="cateCreate" placeholder="Enter facility category" required> <option value="ewaste">E-Waste Collection</option>
</div> <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>
<div class="mb-3"> <div class="mb-3">
<label for="descCreate" class="form-label">Description</label> <label for="createDescription" class="form-label">Description</label>
<div class="input-group"> <textarea class="form-control" id="createDescription" name="description" rows="3" required></textarea>
<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="descCreate" name="descCreate" placeholder="Enter facility description" rows="3" required></textarea>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="hnumCreate" class="form-label">House/Building Number</label> <label for="createLatitude" class="form-label">Latitude</label>
<div class="input-group"> <input type="number" step="any" class="form-control" id="createLatitude" name="lat" required>
<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="hnumCreate" name="hnumCreate" placeholder="Enter number" required>
</div> </div>
</div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="strtCreate" class="form-label">Street Name</label> <label for="createLongitude" class="form-label">Longitude</label>
<div class="input-group"> <input type="number" step="any" class="form-control" id="createLongitude" name="lng" required>
<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="strtCreate" name="strtCreate" placeholder="Enter street name" required>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="townCreate" 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="townCreate" name="townCreate" placeholder="Enter town/city" required>
</div>
</div>
<div class="col-md-6 mb-3">
<label for="cntyCreate" 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="cntyCreate" name="cntyCreate" placeholder="Enter county" required>
</div>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="postCreate" class="form-label">Postcode</label> <label for="createHouseNumber" class="form-label">House Number/Name</label>
<div class="input-group"> <input type="text" class="form-control" id="createHouseNumber" name="houseNumber" required>
<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="postCreate" name="postCreate" placeholder="Enter postcode" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="latCreate" 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="latCreate" name="latCreate" placeholder="Enter latitude" required>
</div>
</div>
<div class="col-md-6 mb-3">
<label for="lngCreate" 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="lngCreate" name="lngCreate" placeholder="Enter longitude" required>
</div>
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="contCreate" class="form-label">Contributor</label> <label for="createStreetName" class="form-label">Street Name</label>
<div class="input-group"> <input type="text" class="form-control" id="createStreetName" name="streetName" required>
<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="contCreate" name="contCreate" placeholder="Auto-filled with your username" disabled required>
</div> </div>
<small class="text-muted">This will be automatically filled with your username</small>
<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>
</form>
<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>
<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"> <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="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="createForm" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="bi bi-plus-circle me-1"></i>Create Facility <i class="bi bi-plus-circle me-1"></i>Create Facility
</button> </button>
</div> </div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?>
<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>

View File

@@ -15,20 +15,20 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Application JavaScript --> <!-- Application JavaScript -->
<!-- Note: simpleAuth.js is already included in the header --> <!-- Note: auth.js is already included in the header -->
<!-- Note: facilityData.js is already included in the header --> <!-- Note: facilityData.js is already included in the header -->
<script src="/public/js/comments.js"></script> <script src="/public/js/comments.js"></script>
<!-- Initialize components --> <!-- initialise components -->
<script> <script>
// Only run initialization if not already done // Only run initialization if not already done
if (!window.initializationComplete) { if (!window.initializationComplete) {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize auth service // initialise auth service
const loginButton = document.querySelector('[data-bs-toggle="modal"]'); const loginButton = document.querySelector('[data-bs-toggle="modal"]');
const loginModal = document.getElementById('loginModal'); const loginModal = document.getElementById('loginModal');
// Initialize all modals // initialise all modals
try { try {
const modalElements = document.querySelectorAll('.modal'); const modalElements = document.querySelectorAll('.modal');
modalElements.forEach(modalElement => { modalElements.forEach(modalElement => {
@@ -53,16 +53,124 @@
console.error('Error initializing modals:', error); console.error('Error initializing modals:', error);
} }
// Initialize auth form handlers // 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 loginForm = document.querySelector('#loginModal form');
const loginError = document.querySelector('#loginError'); const loginError = document.querySelector('#loginError');
const captchaContainer = document.querySelector('.captcha-container'); const captchaContainer = document.querySelector('.captcha-container');
if (loginForm) { if (loginForm) {
// Show CAPTCHA if needed // Show CAPTCHA if needed
if (simpleAuth.needsCaptcha() && captchaContainer) { if (auth.needsCaptcha() && captchaContainer) {
captchaContainer.style.display = 'flex'; 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 // Handle logout button
@@ -70,13 +178,13 @@
if (logoutButton) { if (logoutButton) {
logoutButton.addEventListener('click', async (e) => { logoutButton.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
await simpleAuth.logout(); await auth.logout();
}); });
} }
// Validate token if authenticated // Validate token if authenticated
if (simpleAuth.isAuthenticated()) { if (auth.isAuthenticated()) {
simpleAuth.validateToken().then(valid => { auth.validateToken().then(valid => {
if (!valid) { if (!valid) {
if (!localStorage.getItem('validationAttempted')) { if (!localStorage.getItem('validationAttempted')) {
localStorage.setItem('validationAttempted', 'true'); localStorage.setItem('validationAttempted', 'true');

View File

@@ -12,7 +12,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <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 --> <!-- CSS theme -->
<link href="/public/css/my-style.css" rel="stylesheet"> <link href="/public/css/default.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
<link href="/public/css/bootstrap-icons.css" rel="stylesheet"> <link href="/public/css/bootstrap-icons.css" rel="stylesheet">
@@ -25,7 +25,7 @@
<title>Ecobuddy - <?php echo $view->pageTitle; ?></title> <title>Ecobuddy - <?php echo $view->pageTitle; ?></title>
<!-- Load simplified authentication helper --> <!-- Load simplified authentication helper -->
<script src="/public/js/simpleAuth.js"></script> <script src="/public/js/auth.js"></script>
<!-- Load API client --> <!-- Load API client -->
<script src="/public/js/apiClient.js"></script> <script src="/public/js/apiClient.js"></script>
@@ -49,7 +49,7 @@
if (Array.isArray(initialData) && initialData.length > 0) { if (Array.isArray(initialData) && initialData.length > 0) {
sessionStorage.setItem('facilityData', JSON.stringify(initialData)); sessionStorage.setItem('facilityData', JSON.stringify(initialData));
// Initialize based on DOM state to ensure scripts run at the right time // initialise based on DOM state to ensure scripts run at the right time
if (document.readyState === 'complete' || document.readyState === 'interactive') { if (document.readyState === 'complete' || document.readyState === 'interactive') {
if (typeof initialiseFacilityData === 'function') { if (typeof initialiseFacilityData === 'function') {
initialiseFacilityData(initialData); initialiseFacilityData(initialData);
@@ -66,11 +66,11 @@
// Add client-side authentication check to update UI // Add client-side authentication check to update UI
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Check if user is authenticated on the client side // Check if user is authenticated on the client side
if (window.auth && window.auth.isAuthenticated()) { if (auth && auth.isAuthenticated()) {
console.log('User is authenticated on client side'); console.log('User is authenticated on client side');
// Get user data // Get user data
const user = window.auth.getUser(); const user = auth.getUser();
if (user) { if (user) {
console.log('User data:', user); console.log('User data:', user);
@@ -115,7 +115,7 @@
const logoutButton = document.getElementById('logoutButton'); const logoutButton = document.getElementById('logoutButton');
if (logoutButton) { if (logoutButton) {
logoutButton.addEventListener('click', async function() { logoutButton.addEventListener('click', async function() {
await window.auth.logout(); await auth.logout();
window.location.reload(); window.location.reload();
}); });
} }
@@ -159,95 +159,109 @@
<i class="bi bi-map-fill me-1"></i>Map <i class="bi bi-map-fill me-1"></i>Map
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="/about.php">
<i class="bi bi-info-circle-fill me-1"></i>About
</a>
</li>
</ul> </ul>
<!-- 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">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-sort-alpha-down text-success"></i>
</span>
<select name="sort" class="form-select border-start-0 filter-control" id="sort">
<option value="title">Title</option>
<option value="category">Category</option>
<option value="status">Status</option>
<option value="description">Description</option>
<option value="streetName">Street</option>
<option value="county">County</option>
<option value="town">Town</option>
<option value="postcode">Postcode</option>
<option value="contributor">Contributor</option>
</select>
<select class="form-select sort-control" name="dir" id="dir" data-order="asc">
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-filter-circle-fill text-success"></i>
</span>
<select name="filterCat" class="form-select border-start-0 filter-control" id="filterCat">
<option value="">All</option>
<option value="title">Title</option>
<option value="category">Category</option>
<option value="status">Status</option>
<option value="description">Description</option>
<option value="streetName">Street</option>
<option value="county">County</option>
<option value="town">Town</option>
<option value="postcode">Postcode</option>
<option value="contributor">Contributor</option>
</select>
</div>
<div class="input-group">
<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>
<!-- User account section --> <!-- User account section -->
<div class="ms-lg-3 mt-3 mt-lg-0"> <div class="ms-lg-3 mt-3 mt-lg-0" id="userAuthSection">
<?php if(!$view->user->isLoggedIn()): ?> <!-- This section will be populated by JavaScript based on authentication status -->
<button type="button" class="btn btn-success" id="loginButton" data-bs-toggle="modal" data-bs-target="#loginModal"> <div class="spinner-border spinner-border-sm text-success" role="status">
<i class="bi bi-box-arrow-in-right me-1"></i>Login <span class="visually-hidden">Loading...</span>
</button> </div>
<?php else: ?> </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-menu">
<div class="user-avatar"> <div class="user-avatar">
<i class="bi bi-person-fill text-success"></i> <i class="bi bi-person-fill text-success"></i>
</div> </div>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-light dropdown-toggle" type="button" id="userMenuButton" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-link dropdown-toggle text-dark text-decoration-none" type="button" id="userMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<?php echo $view->user->getUsername(); ?> ${user.username}
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userMenuButton"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userMenuDropdown">
<li><a class="dropdown-item" href="#"><i class="bi bi-person me-2"></i>Profile</a></li> ${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="#"><i class="bi bi-gear me-2"></i>Settings</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><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> <li><button class="dropdown-item" name="logoutButton"><i class="bi bi-box-arrow-right me-2"></i>Logout</button></li>
</ul> </ul>
</div> </div>
</div> </div>
<?php endif; ?> `;
</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>
</div> </div>
</nav> </nav>
<!-- Login Modal --> <!-- Login Modal -->
<?php if(!$view->user->isLoggedIn()): ?>
<div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true"> <div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow"> <div class="modal-content border-0 shadow">
@@ -280,21 +294,27 @@
<div id="loginError" class="alert alert-danger" style="display: none;"></div> <div id="loginError" class="alert alert-danger" style="display: none;"></div>
<div class="row captcha-container" style="display: none;"> <!-- CAPTCHA container (hidden by default) -->
<!-- CAPTCHA Display --> <div class="captcha-container mb-3" style="display: none;">
<div class="col-md-6 mb-3"> <div class="card bg-light">
<label for="captchaCode" class="form-label">CAPTCHA Code</label> <div class="card-body">
<input type="text" class="form-control bg-light" id="captchaCode" name="generatedCaptcha" value="" readonly> <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>
<!-- CAPTCHA Input -->
<div class="col-md-6 mb-3">
<label for="captchaInput" class="form-label">Enter CAPTCHA</label>
<input type="text" class="form-control" id="captchaInput" name="captchaInput" placeholder="Enter code">
</div> </div>
</div> </div>
<div class="d-grid gap-2 mt-4"> <div class="d-grid">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="bi bi-box-arrow-in-right me-2"></i>Login <i class="bi bi-box-arrow-in-right me-2"></i>Login
</button> </button>
@@ -302,15 +322,140 @@
</form> </form>
</div> </div>
<div class="modal-footer bg-light"> <div class="modal-footer bg-light">
<div class="w-100 d-flex justify-content-between align-items-center"> <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>
<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> </div>
</div> </div>
<?php endif; ?>
<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 --> <!-- Main content container -->
<div class="container-fluid py-4 px-3"> <div class="container-fluid py-4 px-3">

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>

View File

@@ -31,21 +31,27 @@
<div id="loginError" class="alert alert-danger" style="display: none;"></div> <div id="loginError" class="alert alert-danger" style="display: none;"></div>
<div class="row captcha-container" style="display: none;"> <!-- CAPTCHA container (hidden by default) -->
<!-- CAPTCHA Display --> <div class="captcha-container mb-3" style="display: none;">
<div class="col-md-6 mb-3"> <div class="card bg-light">
<label for="captchaCode" class="form-label">CAPTCHA Code</label> <div class="card-body">
<input type="text" class="form-control bg-light" id="captchaCode" name="generatedCaptcha" value="" readonly> <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>
<!-- CAPTCHA Input -->
<div class="col-md-6 mb-3">
<label for="captchaInput" class="form-label">Enter CAPTCHA</label>
<input type="text" class="form-control" id="captchaInput" name="captchaInput" placeholder="Enter code">
</div> </div>
</div> </div>
<div class="d-grid gap-2 mt-4"> <div class="d-grid">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="bi bi-box-arrow-in-right me-2"></i>Login <i class="bi bi-box-arrow-in-right me-2"></i>Login
</button> </button>
@@ -61,3 +67,74 @@
</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,2 +0,0 @@
<?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="button" id="logoutButton">Logout</button>

View File

@@ -1,4 +1,4 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-center gap-2"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-center gap-3">
<div class="text-muted small"> <div class="text-muted small">
<span id="paginationInfo" class="d-flex align-items-center"> <span id="paginationInfo" class="d-flex align-items-center">
<i class="bi bi-info-circle me-2 text-success"></i> <i class="bi bi-info-circle me-2 text-success"></i>
@@ -7,8 +7,8 @@
</div> </div>
<!-- Pagination controls --> <!-- Pagination controls -->
<nav aria-label="Facility table pagination"> <nav class="bg-transparent" aria-label="Facility table pagination">
<ul class="pagination pagination-sm mb-0" id="paginationControls"> <ul class="pagination pagination-sm mb-0 border-2 rounded border-success" id="paginationControls">
<!-- First page button --> <!-- First page button -->
<li class="page-item"> <li class="page-item">
<a class="page-link border-0 text-success" href="#" aria-label="First" id="firstPage"> <a class="page-link border-0 text-success" href="#" aria-label="First" id="firstPage">
@@ -46,7 +46,6 @@
<option value="10">10</option> <option value="10">10</option>
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option>
</select> </select>
</div> </div>
</div> </div>

View File

@@ -9,57 +9,14 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body p-4"> <div class="modal-body p-4">
<!-- Add Comment Form (Only shown to logged in users) --> <!-- Comments container - this will be populated by JavaScript -->
<?php if (isset($_SESSION['loggedin']) && $_SESSION['loggedin'] === true && $_SESSION['access'] >= 1): ?>
<div class="mb-4 border-bottom pb-4">
<h6 class="fw-bold mb-3">
<i class="bi bi-plus-circle text-success me-2"></i>Add New Comment
</h6>
<form id="commentForm">
<input type="hidden" name="action" value="status">
<input type="hidden" name="facilityId" id="commentFacilityId" value="">
<div class="mb-3">
<label for="commentText" class="form-label">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="commentText" name="commentText" rows="3" placeholder="Share your thoughts about this facility..." required></textarea>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Post Comment
</button>
</div>
</form>
</div>
<?php endif; ?>
<!-- Existing Comments Section -->
<div>
<h6 class="fw-bold mb-3">
<i class="bi bi-chat-square-dots text-primary me-2"></i>Comments
</h6>
<div id="commentsContainer" class="comments-container"> <div id="commentsContainer" class="comments-container">
<!-- Comments will be loaded here dynamically --> <!-- Comments will be loaded here dynamically -->
<div class="text-center py-4 text-muted" id="noCommentsMessage"> <div class="text-center py-4 text-muted" id="noCommentsMessage">
<i class="bi bi-chat-square-text fs-4 d-block mb-2"></i> <i class="bi bi-chat-square-text fs-4 d-block mb-2"></i>
<p>No comments yet. Be the first to share your thoughts!</p> <p>Loading comments...</p>
</div> </div>
</div> </div>
<!-- Login Prompt for Non-Authenticated Users -->
<?php if (!isset($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true): ?>
<div class="alert alert-info mt-3">
<i class="bi bi-info-circle me-2"></i>
<span>Please <a href="#" data-bs-toggle="modal" data-bs-target="#loginModal" data-bs-dismiss="modal">log in</a> to add your comments.</span>
</div>
<?php endif; ?>
</div>
</div> </div>
<div class="modal-footer bg-light"> <div class="modal-footer bg-light">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>

View File

@@ -1,225 +0,0 @@
import sqlite3
import random
# Connect to the SQLite database
conn = sqlite3.connect('Databases/ecobuddy.sqlite')
cursor = conn.cursor()
# Check if we need to add any new categories
cursor.execute("SELECT id FROM ecoCategories")
existing_categories = [row[0] for row in cursor.fetchall()]
# Add two new categories
new_categories = [
(16, "Urban Farms"),
(17, "Rainwater Harvesting Systems")
]
for category in new_categories:
if category[0] not in existing_categories:
cursor.execute("INSERT INTO ecoCategories (id, name) VALUES (?, ?)", category)
print(f"Added new category: {category[1]}")
# Get list of user IDs for contributors
cursor.execute("SELECT id FROM ecoUser")
user_ids = [row[0] for row in cursor.fetchall()]
# Define 10 new ecological facilities in the UK with accurate location data
new_facilities = [
{
"title": "Community Garden Hackney",
"category": 12, # Pollinator Gardens
"description": "Urban garden with native plants to support local pollinators",
"houseNumber": "45",
"streetName": "Dalston Lane",
"county": "Greater London",
"town": "London",
"postcode": "E8 3AH",
"lng": -0.0612,
"lat": 51.5476,
"contributor": random.choice(user_ids),
"status_comments": [
"Recently expanded with new wildflower section",
"Volunteer days every Saturday"
]
},
{
"title": "Rooftop Solar Farm",
"category": 8, # Green Roofs
"description": "Combined green roof and solar panel installation on commercial building",
"houseNumber": "120",
"streetName": "Deansgate",
"county": "Greater Manchester",
"town": "Manchester",
"postcode": "M3 2QJ",
"lng": -2.2484,
"lat": 53.4808,
"contributor": random.choice(user_ids),
"status_comments": [
"Generates power for the entire building"
]
},
{
"title": "Edinburgh Tool Library",
"category": 15, # Community Tool Libraries
"description": "Borrow tools instead of buying them - reducing waste and consumption",
"houseNumber": "25",
"streetName": "Leith Walk",
"county": "Edinburgh",
"town": "Edinburgh",
"postcode": "EH6 8LN",
"lng": -3.1752,
"lat": 55.9677,
"contributor": random.choice(user_ids),
"status_comments": []
},
{
"title": "Cardiff Bay Water Refill Station",
"category": 9, # Public Water Refill Stations
"description": "Free water refill station to reduce plastic bottle usage",
"houseNumber": "3",
"streetName": "Mermaid Quay",
"county": "Cardiff",
"town": "Cardiff",
"postcode": "CF10 5BZ",
"lng": -3.1644,
"lat": 51.4644,
"contributor": random.choice(user_ids),
"status_comments": [
"Recently cleaned and maintained",
"High usage during summer months"
]
},
{
"title": "Bristol Urban Farm",
"category": 16, # Urban Farms (new category)
"description": "Community-run urban farm providing local produce and education",
"houseNumber": "18",
"streetName": "Stapleton Road",
"county": "Bristol",
"town": "Bristol",
"postcode": "BS5 0RA",
"lng": -2.5677,
"lat": 51.4635,
"contributor": random.choice(user_ids),
"status_comments": [
"Open for volunteers Tuesday-Sunday"
]
},
{
"title": "Newcastle Rainwater Collection System",
"category": 17, # Rainwater Harvesting Systems (new category)
"description": "Public demonstration of rainwater harvesting for garden irrigation",
"houseNumber": "55",
"streetName": "Northumberland Street",
"county": "Tyne and Wear",
"town": "Newcastle upon Tyne",
"postcode": "NE1 7DH",
"lng": -1.6178,
"lat": 54.9783,
"contributor": random.choice(user_ids),
"status_comments": []
},
{
"title": "Brighton Beach Solar Bench",
"category": 7, # Solar-Powered Benches
"description": "Solar-powered bench with USB charging ports and WiFi",
"houseNumber": "",
"streetName": "Kings Road",
"county": "East Sussex",
"town": "Brighton",
"postcode": "BN1 2FN",
"lng": -0.1426,
"lat": 50.8214,
"contributor": random.choice(user_ids),
"status_comments": [
"Popular spot for tourists",
"One USB port currently not working"
]
},
{
"title": "Leeds Community Compost Hub",
"category": 6, # Community Compost Bins
"description": "Large-scale community composting facility for local residents",
"houseNumber": "78",
"streetName": "Woodhouse Lane",
"county": "West Yorkshire",
"town": "Leeds",
"postcode": "LS2 9JT",
"lng": -1.5491,
"lat": 53.8067,
"contributor": random.choice(user_ids),
"status_comments": [
"Recently expanded capacity"
]
},
{
"title": "Glasgow EV Charging Hub",
"category": 4, # Public EV Charging Stations
"description": "Multi-vehicle EV charging station with fast chargers",
"houseNumber": "42",
"streetName": "Buchanan Street",
"county": "Glasgow",
"town": "Glasgow",
"postcode": "G1 3JX",
"lng": -4.2526,
"lat": 55.8621,
"contributor": random.choice(user_ids),
"status_comments": [
"6 charging points available",
"24/7 access"
]
},
{
"title": "Oxford E-Waste Collection Center",
"category": 13, # E-Waste Collection Bins
"description": "Dedicated facility for proper disposal and recycling of electronic waste",
"houseNumber": "15",
"streetName": "St Aldate's",
"county": "Oxfordshire",
"town": "Oxford",
"postcode": "OX1 1BX",
"lng": -1.2577,
"lat": 51.7520,
"contributor": random.choice(user_ids),
"status_comments": []
}
]
# Insert facilities into the database
for facility in new_facilities:
cursor.execute("""
INSERT INTO ecoFacilities
(title, category, description, houseNumber, streetName, county, town, postcode, lng, lat, contributor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
facility["title"],
facility["category"],
facility["description"],
facility["houseNumber"],
facility["streetName"],
facility["county"],
facility["town"],
facility["postcode"],
facility["lng"],
facility["lat"],
facility["contributor"]
))
# Get the ID of the inserted facility
facility_id = cursor.lastrowid
# Add status comments if any
for comment in facility["status_comments"]:
cursor.execute("""
INSERT INTO ecoFacilityStatus (facilityId, statusComment)
VALUES (?, ?)
""", (facility_id, comment))
print(f"Added facility: {facility['title']} in {facility['town']}")
# Commit the changes and close the connection
conn.commit()
conn.close()
print("\nSuccessfully added 10 new ecological facilities to the database.")

View File

@@ -1,33 +0,0 @@
<?php
/**
* Admin-only API endpoint example
*
* This endpoint demonstrates how to protect an API route for admin users only
* using our simplified authentication approach.
*/
require_once('../Models/User.php');
// Set content type to JSON
header('Content-Type: application/json');
// Check if user is an admin
$auth = User::checkAdmin();
if (!$auth) {
// The checkAdmin method already sent the error response
exit;
}
// User is an admin, proceed with the admin-only logic
$response = [
'status' => 'success',
'message' => 'You have access to this admin-only endpoint',
'user' => [
'id' => $auth['uid'],
'username' => $auth['username'],
'accessLevel' => $auth['accessLevel']
]
];
// Send response
echo json_encode($response);

View File

@@ -1,51 +0,0 @@
<?php
/**
* Login API endpoint
*
* This endpoint handles user authentication and returns a JWT token
* if the credentials are valid.
*/
require_once('../Models/User.php');
// Set content type to JSON
header('Content-Type: application/json');
// Only allow POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Get JSON request body
$json = file_get_contents('php://input');
$data = json_decode($json, true);
// Validate request data
if (!$data || !isset($data['username']) || !isset($data['password'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request data']);
exit;
}
// Authenticate user
$user = new User();
$token = $user->Authenticate($data['username'], $data['password']);
if ($token) {
// Authentication successful
echo json_encode([
'success' => true,
'token' => $token,
'user' => [
'id' => $user->getUserId(),
'username' => $user->getUsername(),
'accessLevel' => $user->getAccessLevel()
]
]);
} else {
// Authentication failed
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
}

View File

@@ -1,33 +0,0 @@
<?php
/**
* Protected API endpoint example
*
* This endpoint demonstrates how to protect an API route using
* our simplified authentication approach.
*/
require_once('../Models/User.php');
// Set content type to JSON
header('Content-Type: application/json');
// Check if user is authenticated
$auth = User::checkAuth();
if (!$auth) {
// The checkAuth method already sent the error response
exit;
}
// User is authenticated, proceed with the endpoint logic
$response = [
'status' => 'success',
'message' => 'You have access to this protected endpoint',
'user' => [
'id' => $auth['uid'],
'username' => $auth['username'],
'accessLevel' => $auth['accessLevel']
]
];
// Send response
echo json_encode($response);

View File

@@ -3,12 +3,19 @@ require_once('Models/AuthService.php');
require_once('Models/UserDataSet.php'); require_once('Models/UserDataSet.php');
require_once('Models/User.php'); require_once('Models/User.php');
// Enable CORS // Enable CORS with more restrictive settings
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *'); // Would be set to domain. Move to .env file
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Content-Type: application/json'); 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 // Handle OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200); http_response_code(200);
@@ -23,6 +30,22 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true); $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 // Handle token refresh
if (isset($data['action']) && $data['action'] === 'refresh') { if (isset($data['action']) && $data['action'] === 'refresh') {
if (!isset($data['refreshToken'])) { if (!isset($data['refreshToken'])) {
@@ -54,11 +77,46 @@ try {
exit; 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 // Authenticate user
$user = new User(); $user = new User();
$token = $user->Authenticate($data['username'], $data['password']); $token = $user->Authenticate($data['username'], hash('sha256', $data['password']));
if ($token) { if ($token) {
// Reset login attempts on successful login
$_SESSION['login_attempts'] = 0;
// Generate refresh token // Generate refresh token
$refreshToken = $auth->generateRefreshToken([ $refreshToken = $auth->generateRefreshToken([
'id' => $user->getUserId(), 'id' => $user->getUserId(),
@@ -77,8 +135,14 @@ try {
] ]
]); ]);
} else { } else {
// Increment login attempts
$_SESSION['login_attempts'] = ($loginAttempts ?? 0) + 1;
http_response_code(401); http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']); echo json_encode([
'error' => 'Invalid credentials',
'captcha' => $_SESSION['login_attempts'] >= 3
]);
} }
exit; exit;
} }

16988
debug.log

File diff suppressed because it is too large Load Diff

View File

@@ -147,10 +147,32 @@ try {
break; break;
case 'getStatuses': case 'getStatuses':
$facilityId = $_POST['facilityId']; if (!isset($_POST['facilityId'])) {
$statuses = $facilityDataSet->getFacilityStatuses($facilityId); http_response_code(400);
echo json_encode(['error' => 'Facility ID is required']);
break;
}
echo json_encode(['success' => true, 'statuses' => $statuses]); $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; break;
case 'editStatus': case 'editStatus':

View File

@@ -1,6 +1,5 @@
<?php <?php
// load dataset // load dataset
require_once('Models/UserDataSet.php');
require_once('Models/FacilityDataSet.php'); require_once('Models/FacilityDataSet.php');
// make a view class // make a view class
@@ -16,11 +15,6 @@ if ($view->facilityDataSet === false) {
error_log('Error fetching facility data'); error_log('Error fetching facility data');
} }
// load login controller
require_once("logincontroller.php");
$view->user = new User();
// load main view // load main view
require_once('Views/index.phtml'); require_once('Views/index.phtml');

View File

@@ -1,158 +0,0 @@
<?php
require_once("Models/User.php");
require_once("Models/AuthService.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');
// Handle OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Create service objects
$authService = new AuthService();
// Start session for CAPTCHA handling only
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Handle AJAX requests
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
header('Content-Type: application/json');
if (isset($_POST["action"])) {
switch ($_POST["action"]) {
case "login":
$username = $_POST["username"];
$password = hash("sha256", $_POST["password"]);
// Check if CAPTCHA is required (after 3 failed attempts)
$loginAttempts = isset($_SESSION['loginAttempts']) ? $_SESSION['loginAttempts'] : 0;
if ($loginAttempts >= 3) {
// Validate CAPTCHA
if (!isset($_POST["captchaInput"]) || !isset($_SESSION['captcha']) ||
$_POST["captchaInput"] !== $_SESSION['captcha']) {
// Generate new CAPTCHA for next attempt
$captcha = substr(str_shuffle("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), 0, 5);
$_SESSION['captcha'] = $captcha;
echo json_encode([
"error" => "Incorrect CAPTCHA.",
"captcha" => $captcha
]);
exit;
}
// Clear CAPTCHA after successful validation
unset($_SESSION['captcha']);
}
// Authenticate user
$user = new User();
$token = $user->Authenticate($username, $password);
if ($token) {
// Reset login attempts on successful login
$_SESSION['loginAttempts'] = 0;
// Generate refresh token
$refreshToken = $authService->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()
],
'redirect' => '/'
]);
} else {
// Increment login attempts
$_SESSION['loginAttempts'] = $loginAttempts + 1;
// If this failure triggers CAPTCHA, generate it
if ($_SESSION['loginAttempts'] >= 3) {
$captcha = substr(str_shuffle("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), 0, 5);
$_SESSION['captcha'] = $captcha;
echo json_encode([
"error" => "Invalid username or password.",
"captcha" => $captcha
]);
} else {
echo json_encode(["error" => "Invalid username or password."]);
}
}
break;
case "logout":
// Clear session (only used for CAPTCHA)
session_unset();
session_destroy();
echo json_encode([
"success" => true,
"redirect" => '/'
]);
break;
case "refresh":
// Validate refresh token
if (!isset($_POST['refreshToken'])) {
echo json_encode([
"valid" => false,
"error" => "No refresh token provided"
]);
exit;
}
$refreshToken = $_POST['refreshToken'];
$newToken = $authService->refreshToken($refreshToken);
if ($newToken) {
echo json_encode([
"valid" => true,
"token" => $newToken
]);
} else {
echo json_encode([
"valid" => false,
"error" => "Invalid or expired refresh token"
]);
}
break;
case "validate":
// Validate JWT token using the simplified approach
$auth = User::checkAuth(false);
if ($auth) {
echo json_encode([
"valid" => true,
"user" => [
"id" => $auth['uid'],
"username" => $auth['username'],
"accessLevel" => $auth['accessLevel']
]
]);
} else {
echo json_encode([
"valid" => false
]);
}
break;
}
exit;
}
}

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

@@ -5,7 +5,9 @@
* authentication and common request patterns. * authentication and common request patterns.
* *
* The client uses JWT tokens for authentication, which are automatically * The client uses JWT tokens for authentication, which are automatically
* included in requests via the authFetch function provided by the auth service. * 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 { class ApiClient {
/** /**
@@ -16,12 +18,26 @@ class ApiClient {
*/ */
constructor() { constructor() {
// Ensure auth service is available // Ensure auth service is available
if (!window.auth) { if (!auth) {
console.error('Auth service not available'); console.error('Auth service not available');
} }
// Create authenticated fetch function if not already available // Use the fetchAuth method from auth
this.authFetch = window.authFetch || window.auth?.createAuthFetch() || fetch; 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;
}
};
} }
/** /**
@@ -115,7 +131,36 @@ class ApiClient {
formData.append(key, value); formData.append(key, value);
}); });
return this.post('/facilitycontroller.php', formData); 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;
}
} }
/** /**
@@ -183,12 +228,15 @@ class ApiClient {
* *
* This method adds a new status update to a facility. * This method adds a new status update to a facility.
* *
* @param {number|string} idStatus - The facility ID * @param {number|string} facilityId - The facility ID
* @param {string} updateStatus - The status comment * @param {string} statusComment - The status comment
* @returns {Promise<Object>} The response data * @returns {Promise<Object>} The response data
*/ */
async addFacilityStatus(idStatus, updateStatus) { async addFacilityStatus(facilityId, statusComment) {
return this.facility('status', { idStatus, updateStatus }); return this.facility('status', {
facilityId: facilityId,
statusComment: statusComment
});
} }
/** /**
@@ -202,7 +250,11 @@ class ApiClient {
* @returns {Promise<Object>} The response data * @returns {Promise<Object>} The response data
*/ */
async updateFacilityStatus(statusId, editStatus, facilityId) { async updateFacilityStatus(statusId, editStatus, facilityId) {
return this.facility('editStatus', { statusId, editStatus, facilityId }); return this.facility('editStatus', {
statusId: statusId,
statusComment: editStatus,
facilityId: facilityId
});
} }
/** /**
@@ -217,90 +269,9 @@ class ApiClient {
async deleteFacilityStatus(statusId, facilityId) { async deleteFacilityStatus(statusId, facilityId) {
return this.facility('deleteStatus', { statusId, facilityId }); return this.facility('deleteStatus', { statusId, facilityId });
} }
/**
* Authenticates a user
*
* This method sends a login request with the provided credentials.
* It uses a direct fetch call rather than authFetch since the user
* isn't authenticated yet.
*
* @param {string} username - The username
* @param {string} password - The password
* @param {string} captchaInput - The CAPTCHA input (optional)
* @returns {Promise<Object>} The response data
*/
async login(username, password, captchaInput = null) {
const formData = new FormData();
formData.append('action', 'login');
formData.append('username', username);
formData.append('password', password);
if (captchaInput) {
formData.append('captchaInput', captchaInput);
}
const response = await fetch('/logincontroller.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
return await response.json();
}
/**
* Refreshes the access token
*
* This method sends a request to refresh an expired JWT token
* using the provided refresh token.
*
* @param {string} refreshToken - The refresh token
* @returns {Promise<Object>} The response data
*/
async refreshToken(refreshToken) {
const formData = new FormData();
formData.append('action', 'refresh');
formData.append('refreshToken', refreshToken);
const response = await fetch('/logincontroller.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
return await response.json();
}
/**
* Logs out the current user
*
* This method sends a logout request to invalidate the session.
* Note that client-side token removal is handled separately.
*
* @returns {Promise<Object>} The response data
*/
async logout() {
const formData = new FormData();
formData.append('action', 'logout');
const response = await fetch('/logincontroller.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
return await response.json();
}
} }
// Initialize API client // initialise API client
const api = new ApiClient(); const api = new ApiClient();
// Export API client // Export API client

View File

@@ -1,89 +1,113 @@
/** /**
* Authentication service for handling user login, logout, and token management * Authentication Worker
* *
* This class provides a complete authentication solution using JWT tokens. * I admit JWT is unnecessary, but I did it anyway because it was interesting
* It handles token storage, validation, automatic refresh, and authenticated * and I wanted to try it out.
* API requests.
*/ */
class AuthService { class Auth {
/** /**
* Initialises the authentication service * initialise the authentication helper
*
* Loads existing token and user data from localStorage and sets up
* automatic token refresh. This ensures the user stays logged in
* across page refreshes and that tokens are refreshed before they expire.
*/ */
constructor() { constructor() {
this.token = localStorage.getItem('token'); this.token = localStorage.getItem('token');
this.refreshToken = localStorage.getItem('refreshToken'); this.user = JSON.parse(localStorage.getItem('user') || 'null');
this.user = JSON.parse(localStorage.getItem('user'));
this.isValidating = false;
this.loginAttempts = parseInt(localStorage.getItem('loginAttempts') || '0'); this.loginAttempts = parseInt(localStorage.getItem('loginAttempts') || '0');
this.refreshing = false; this.isValidating = false;
this.validationPromise = null;
// Set up token refresh interval // Generate a browser fingerprint
this.setupTokenRefresh(); 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)
}
} }
/** /**
* Sets up automatic token refresh * Generate a simple browser fingerprint, super unnecessary and out of scope
* * but it was simple and hardens the authentication a bit.
* Creates an interval that checks token validity every minute and * @private
* refreshes it if needed. This helps prevent the user from being * @returns {string} A fingerprint based on browser properties
* logged out due to token expiration during active use of the application.
*/ */
setupTokenRefresh() { _generateFingerprint() {
// Check token every minute const components = [
setInterval(() => { navigator.userAgent,
this.checkAndRefreshToken(); navigator.language,
}, 60000); // 1 minute screen.colorDepth,
screen.width + 'x' + screen.height,
new Date().getTimezoneOffset()
];
// Also check immediately // Create a hash of the components
this.checkAndRefreshToken(); 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);
} }
/** /**
* Checks if token needs refreshing and refreshes if needed * 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
* This method examines the current token's expiry time and refreshes * new browser automatically logged me in.)
* it if it's about to expire (within 5 minutes). This provides * This should be called when the page loads to ensure the token is valid
* seamless authentication * @returns {Promise<boolean>} True if token is valid, false otherwise
*/ */
async checkAndRefreshToken() { async validateOnLoad() {
// Skip if already refreshing or no token exists // If already validating, return the existing promise
if (this.refreshing || !this.token || !this.refreshToken) return; 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 { try {
this.refreshing = true; const isValid = await this.validateToken();
// Check if token is about to expire (within 5 minutes) if (!isValid) {
const payload = this.parseJwt(this.token); // Token is invalid, try to refresh it
if (!payload || !payload.exp) return; const refreshed = await this.refreshToken();
const expiryTime = payload.exp * 1000; // Convert to milliseconds if (!refreshed) {
const currentTime = Date.now(); // Refresh failed, logout
const timeToExpiry = expiryTime - currentTime; this.logout(false); // Silent logout (no redirect)
return false;
// If token expires in less than 5 minutes, refresh it
if (timeToExpiry < 300000) { // 5 minutes in milliseconds
await this.refreshAccessToken();
} }
return true;
}
return isValid;
} catch (error) { } catch (error) {
console.error('Token refresh check failed:', error); console.error('Token validation error:', error);
this.logout(false); // Silent logout (no redirect)
return false;
} finally { } finally {
this.refreshing = false; this.isValidating = false;
this.validationPromise = null;
} }
})();
return this.validationPromise;
} }
/** /**
* Parses a JWT token to extract its payload * Parse a JWT token to extract its payload
*
* This utility method decodes the JWT token without verifying
* its signature. It's used internally to check token expiry
* and extract user information.
*
* @param {string} token - The JWT token to parse * @param {string} token - The JWT token to parse
* @returns {Object|null} The decoded token payload or null if invalid * @returns {object|null} The decoded payload or null if invalid
*/ */
parseJwt(token) { parseJwt(token) {
try { try {
@@ -94,521 +118,252 @@ class AuthService {
}).join('')); }).join(''));
return JSON.parse(jsonPayload); return JSON.parse(jsonPayload);
} catch (error) { } catch (e) {
console.error('Error parsing JWT token:', e);
return null; return null;
} }
} }
/** /**
* Refreshes the access token using the refresh token * Generate a new CAPTCHA
* * @returns {Promise<string>} The generated CAPTCHA code
* This method sends the refresh token to the server to obtain
* a new access token when the current one is about to expire.
* It's a crucial part of maintaining persistent authentication.
*
* @returns {Promise<boolean>} True if refresh was successful, false otherwise
*/ */
async refreshAccessToken() { async generateCaptcha() {
if (!this.refreshToken) return false;
try { try {
console.log('Attempting to refresh access token...'); const response = await fetch('/auth.php', {
const formData = new FormData();
formData.append('action', 'refresh');
formData.append('refreshToken', this.refreshToken);
const response = await fetch('/logincontroller.php', {
method: 'POST', method: 'POST',
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest' 'Content-Type': 'application/json'
}, },
body: formData body: JSON.stringify({
action: 'generateCaptcha'
})
}); });
console.log('Token refresh response status:', response.status);
const data = await response.json(); const data = await response.json();
console.log('Token refresh response data:', data);
if (data.valid && data.token) {
console.log('Token refresh successful');
this.token = data.token;
localStorage.setItem('token', data.token);
return true;
}
// If refresh failed, log out
if (!data.valid) {
console.log('Token refresh failed, logging out');
this.logout();
}
return false;
} catch (error) {
console.error('Token refresh error details:', error);
error_log('Token refresh failed:', error);
return false;
}
}
/**
* Authenticates a user with the provided credentials
*
* This method sends the login credentials to the server,
* stores the returned tokens and user data if successful,
* and updates the login attempts counter for CAPTCHA handling.
*
* @param {FormData} formData - The form data containing login credentials
* @returns {Promise<Object>} The login result
*/
async login(formData) {
try {
console.log('Attempting to login with URL:', '/logincontroller.php');
const response = await fetch('/logincontroller.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
console.log('Login response status:', response.status);
const data = await response.json();
console.log('Login response data:', data);
if (data.error) {
// If server sends a new CAPTCHA, update it
if (data.captcha) { if (data.captcha) {
const captchaCode = document.getElementById('captchaCode'); return data.captcha;
const captchaContainer = document.querySelector('.captcha-container');
if (captchaCode && captchaContainer) {
captchaCode.value = data.captcha;
captchaContainer.style.display = 'flex';
} }
} throw new Error('Failed to generate CAPTCHA');
throw new Error(data.error);
}
if (data.success && data.token) {
this.token = data.token;
this.refreshToken = data.refreshToken;
this.user = data.user;
localStorage.setItem('token', data.token);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
// Reset login attempts on successful login
localStorage.removeItem('loginAttempts');
// Handle redirect if provided
if (data.redirect) {
window.location.href = data.redirect;
}
return data.user;
}
throw new Error('Login failed');
} catch (error) { } catch (error) {
console.error('Login error details:', error); console.error('Error generating CAPTCHA:', error);
error_log('Login error:', error);
throw error; throw error;
} }
} }
/** /**
* Logs the user out * Check if CAPTCHA is needed for login
* * @returns {boolean} True if CAPTCHA is needed, false otherwise
* This method clears all authentication data from localStorage
* and notifies the server about the logout. It also updates the
* UI to reflect the logged-out state.
*
* @returns {Promise<Object>} The logout result
*/
async logout() {
try {
const formData = new FormData();
formData.append('action', 'logout');
const response = await fetch('/logincontroller.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
const data = await response.json();
if (!data.success) {
throw new Error('Logout failed');
}
this.token = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// Handle redirect if provided
if (data.redirect) {
window.location.href = data.redirect;
}
} catch (error) {
error_log('Logout error:', error);
throw error;
}
}
/**
* Checks if the user is currently authenticated
*
* This is a simple helper method that returns true if
* the user object exists, indicating an authenticated user.
*
* @returns {boolean} True if authenticated, false otherwise
*/
isAuthenticated() {
return !!this.token;
}
/**
* Checks if the current user has admin privileges
*
* This helper method checks if the user is authenticated
* and has an access level of 1, which indicates admin status.
*
* @returns {boolean} True if the user is an admin, false otherwise
*/
isAdmin() {
return this.user && this.user.accessLevel === 1;
}
/**
* Checks if CAPTCHA is required for login
*
* This method determines if a CAPTCHA should be shown during login
* based on the number of failed login attempts. This helps prevent
* brute force attacks on the login system.
*
* @returns {boolean} True if CAPTCHA is required, false otherwise
*/ */
needsCaptcha() { needsCaptcha() {
return this.loginAttempts >= 3; return this.loginAttempts >= 3;
} }
/** /**
* Validates the current token with the server * Login a user based on credentials.
* * @param {object} credentials - The user credentials (username, password, captchaInput)
* This method checks if the current token is still valid by * @returns {Promise<object>} The login result
* making a request to the server. It's used to verify authentication */
* status when the application loads. 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 * @returns {Promise<boolean>} True if token is valid, false otherwise
*/ */
async validateToken() { async validateToken() {
// Skip validation if no token exists
if (!this.token) return false;
// Prevent multiple simultaneous validations
if (this.isValidating) return true;
try { try {
console.log('Validating token...'); if (!this.token) {
this.isValidating = true; return false;
}
const response = await fetch('/auth.php', { const response = await fetch('/auth.php', {
method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${this.token}` 'Authorization': `Bearer ${this.token}`
} }
}); });
console.log('Token validation response status:', response.status); 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(); const data = await response.json();
console.log('Token validation response data:', data);
// Handle invalid token if (data.success && data.token) {
if (!response.ok || !data.valid) { this.token = data.token;
console.log('Token is invalid, attempting to refresh...'); localStorage.setItem('token', data.token);
// Try to refresh the token
if (this.refreshToken) {
console.log('Refresh token exists, attempting to refresh...');
const refreshed = await this.refreshAccessToken();
if (refreshed) {
console.log('Token refreshed successfully');
return true; return true;
} }
}
console.log('Token refresh failed, logging out');
this.logout();
return false; return false;
}
console.log('Token is valid');
return true;
} catch (error) { } catch (error) {
console.error('Token validation error details:', error); console.error('Token refresh error:', error);
error_log('Token validation error:', error);
return false;
} finally {
this.isValidating = false;
}
}
/**
* Gets the current user object
*
* This helper method returns the user object containing
* information about the authenticated user.
*
* @returns {Object|null} The user object or null if not authenticated
*/
getUser() {
console.log('Getting user from auth service:', this.user);
if (!this.user) {
// Try to get user from localStorage as a fallback
const localUser = localStorage.getItem('user');
console.log('User not found in auth service, checking localStorage:', localUser);
if (localUser) {
try {
this.user = JSON.parse(localUser);
} catch (e) {
console.error('Error parsing user from localStorage:', e);
}
}
}
return this.user;
}
/**
* Gets the current JWT token
*
* This helper method returns the current JWT token for
* use in authenticated API requests.
*
* @returns {string|null} The JWT token or null if not authenticated
*/
getToken() {
console.log('Getting token from auth service:', this.token);
if (!this.token) {
// Try to get token from localStorage as a fallback
const localToken = localStorage.getItem('token');
console.log('Token not found in auth service, checking localStorage:', localToken);
if (localToken) {
this.token = localToken;
}
}
return this.token;
}
/**
* Creates an authenticated fetch function
*
* This method returns a wrapper around the fetch API that
* automatically includes the JWT token in the Authorization header.
* It also handles token refresh if a request fails due to an expired token.
*
* @returns {Function} The authenticated fetch function
*/
createAuthFetch() {
return async (url, options = {}) => {
// Ensure options.headers exists
options.headers = options.headers || {};
// Add Authorization header if token exists
if (this.token) {
options.headers['Authorization'] = `Bearer ${this.token}`;
}
// Add X-Requested-With header for AJAX requests
options.headers['X-Requested-With'] = 'XMLHttpRequest';
try {
// Make the request
const response = await fetch(url, options);
// If unauthorized (401), try to refresh the token and retry
if (response.status === 401 && this.refreshToken) {
console.log('Received 401 response, attempting to refresh token...');
const refreshed = await this.refreshAccessToken();
if (refreshed) {
console.log('Token refreshed, retrying request...');
// Update Authorization header with new token
options.headers['Authorization'] = `Bearer ${this.token}`;
// Retry the request
return fetch(url, options);
}
console.log('Token refresh failed, returning 401 response');
}
return response;
} catch (error) {
console.error('Auth fetch error details:', error);
error_log('Auth fetch error:', error);
throw error;
}
};
}
/**
* Checks if the token is valid by parsing it
*
* This method checks if the token is valid by parsing it and checking
* if it has expired. It doesn't make a server request, so it's only
* a client-side check.
*
* @returns {boolean} True if the token is valid, false otherwise
*/
isTokenValid() {
const token = this.getToken();
if (!token) {
console.log('No token found');
return false;
}
try {
const payload = this.parseJwt(token);
console.log('Token payload:', payload);
if (!payload || !payload.exp) {
console.log('Token payload is invalid or missing expiry');
return false;
}
const now = Math.floor(Date.now() / 1000);
const isValid = payload.exp > now;
console.log('Token expiry:', new Date(payload.exp * 1000));
console.log('Current time:', new Date(now * 1000));
console.log('Token is valid:', isValid);
return isValid;
} catch (e) {
console.error('Error parsing token:', e);
return false; return false;
} }
} }
} }
// Initialize authentication service // Create a global instance and expose it
const auth = new AuthService(); window.auth = new Auth();
// Create authenticated fetch function // Log that auth is ready
const authFetch = auth.createAuthFetch(); console.log('auth is ready and exposed to window');
// Set up authentication handlers when DOM is loaded // Dispatch a custom event to notify other scripts
document.addEventListener('DOMContentLoaded', () => { window.dispatchEvent(new Event('authReady'));
console.log('DOM loaded, setting up authentication handlers');
// Get modal elements
const loginButton = document.querySelector('[data-bs-toggle="modal"]');
const loginModal = document.getElementById('loginModal');
// Set up login form handlers
const loginForm = document.querySelector('#loginModal form');
const loginError = document.querySelector('#loginError');
const captchaContainer = document.querySelector('.captcha-container');
if (loginForm) {
// Handle form submission
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(loginForm);
formData.append('action', 'login');
try {
await auth.login(formData);
// Hide modal before reload
const modal = bootstrap.Modal.getInstance(loginModal);
if (modal) {
modal.hide();
}
// Refresh the page using replace to prevent form resubmission
window.location.replace(window.location.href);
} catch (error) {
if (loginError) {
loginError.textContent = error.message;
loginError.style.display = 'block';
}
// Show CAPTCHA after 3 failed attempts
if (auth.needsCaptcha() && captchaContainer) {
captchaContainer.style.display = 'flex';
}
}
});
}
// Set up logout button handler
const logoutButton = document.getElementById('logoutButton');
if (logoutButton) {
logoutButton.addEventListener('click', async (e) => {
e.preventDefault();
try {
await auth.logout();
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Validate token if authenticated
if (auth.isAuthenticated()) {
console.log('User is authenticated, validating token...');
// Check if we're already in a validation cycle
const validationCycle = sessionStorage.getItem('validationCycle');
if (validationCycle) {
console.log('Already in validation cycle, forcing logout');
// We've already tried to validate in this page load
// Force a proper logout
sessionStorage.removeItem('validationCycle');
auth.logout().finally(() => {
window.location.replace('/');
});
return;
}
// Set validation cycle flag
sessionStorage.setItem('validationCycle', 'true');
auth.validateToken().then(valid => {
console.log('Token validation result:', valid);
if (!valid) {
console.log('Token is invalid, redirecting to login page');
// Token is invalid, redirect to login page
window.location.replace('/');
} else {
console.log('Token is valid, clearing validation cycle flag');
// Clear validation cycle flag
sessionStorage.removeItem('validationCycle');
}
});
} else {
console.log('User is not authenticated');
}
});
// Export auth service and authenticated fetch
window.auth = auth;
window.authFetch = authFetch;
// Make parseJwt accessible from outside
window.auth.parseJwt = auth.parseJwt.bind(auth);
/**
* Custom error logging function
*
* This utility function provides a consistent way to log errors
* throughout the application. It includes both a message and the
* error object for better debugging.
*
* @param {string} message - The error message
* @param {Error} error - The error object
*/
function error_log(message, error) {
console.error(message, error);
}

View File

@@ -1,62 +1,99 @@
/** /**
* Comments functionality for facility management * Facility status (comments) manager for adding, removing and editing user comments.
*/ */
document.addEventListener('DOMContentLoaded', function() {
console.log('Comments.js loaded');
// Initialize comment modal handlers // Create a namespace to avoid global scope conflicts with facilityData.js
initializeCommentModals(); 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 // Set up form handlers
setupCommentFormHandlers(); this.setupCommentFormHandlers();
});
/** console.log('Comments initialised with auth state:', {
* Initialize comment modals isAuthenticated: this.isAuthenticated(),
user: window.auth.getUser()
});
this.state.isInitialised = true;
},
/**
* Check if initialisation possible
*/ */
function initializeCommentModals() { 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) // Status modal (comments view)
const statusModal = document.getElementById('statusModal'); const statusModal = document.getElementById('statusModal');
if (statusModal) { if (statusModal) {
statusModal.addEventListener('show.bs.modal', function(event) { statusModal.addEventListener('show.bs.modal', (event) => {
console.log('Comments modal is about to show'); // Get facility ID from either the button or the modal's data attribute
// Get the button that triggered the modal let facilityId;
const button = event.relatedTarget;
// Get the facility ID from the data attribute // First try to get it from the button that triggered the modal
const facilityId = button.getAttribute('data-facility-id'); if (event.relatedTarget) {
console.log('Facility ID for comments:', facilityId); 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) { if (!facilityId) {
console.error('No facility ID found for comments'); console.error('No facility ID found for comments');
return; return;
} }
// Set the facility ID in the comment form // Store the facility ID on the modal for later use
const commentForm = document.getElementById('commentForm'); statusModal.setAttribute('data-facility-id', facilityId);
if (commentForm) {
const facilityIdInput = commentForm.querySelector('#commentFacilityId');
if (facilityIdInput) {
facilityIdInput.value = facilityId;
}
}
// Load facility comments // Load facility comments
loadFacilityComments(facilityId); this.loadFacilityComments(facilityId);
}); });
} }
// Edit comment modal // Edit comment modal
const editCommentModal = document.getElementById('editCommentModal'); const editCommentModal = document.getElementById('editCommentModal');
if (editCommentModal) { if (editCommentModal) {
editCommentModal.addEventListener('show.bs.modal', function(event) { editCommentModal.addEventListener('show.bs.modal', (event) => {
console.log('Edit comment modal is about to show');
// The button that triggered the modal will pass data via dataset
const button = event.relatedTarget; const button = event.relatedTarget;
const commentId = button.getAttribute('data-comment-id'); const commentId = button.getAttribute('data-comment-id');
const commentText = button.getAttribute('data-comment-text'); const commentText = button.getAttribute('data-comment-text');
console.log('Comment ID:', commentId, 'Comment text:', commentText);
// Set the comment ID and text in the form // Set the comment ID and text in the form
const editForm = document.getElementById('editCommentForm'); const editForm = document.getElementById('editCommentForm');
if (editForm) { if (editForm) {
@@ -70,69 +107,75 @@ function initializeCommentModals() {
} }
}); });
} }
} },
/** /**
* Set up comment form handlers * Set up comment form handlers
*/ */
function setupCommentFormHandlers() { setupCommentFormHandlers() {
// Comment form handler // Comment form handler
const commentForm = document.getElementById('commentForm'); const commentForm = document.getElementById('commentForm');
if (commentForm) { if (commentForm) {
setupCommentFormHandler(commentForm); this.setupCommentFormHandler(commentForm);
} }
// Edit comment form handler // Edit comment form handler
const editCommentForm = document.getElementById('editCommentForm'); const editCommentForm = document.getElementById('editCommentForm');
if (editCommentForm) { if (editCommentForm) {
setupEditCommentFormHandler(editCommentForm); this.setupEditCommentFormHandler(editCommentForm);
} }
} },
/** /**
* Set up a single comment form handler * Set up a single comment form handler
* @param {HTMLFormElement} commentForm - The comment form element
*/ */
function setupCommentFormHandler(commentForm) { setupCommentFormHandler(commentForm) {
commentForm.addEventListener('submit', async function(e) { commentForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
// Prevent duplicate submissions // Prevent duplicate submissions
if (this.submitting) { if (commentForm.submitting) {
return; return;
} }
this.submitting = true; commentForm.submitting = true;
// Check if user is authenticated // Check if user is authenticated
if (!isAuthenticated()) { if (!this.isAuthenticated()) {
alert('You must be logged in to add comments'); alert('You must be logged in to add comments');
this.submitting = false; commentForm.submitting = false;
return; return;
} }
const formData = new FormData(this); const formData = new FormData(commentForm);
// Get form data // Get form data and ensure proper types
const commentText = formData.get('commentText'); const statusComment = formData.get('commentText');
const facilityId = formData.get('facilityId'); const facilityId = formData.get('facilityId');
console.log('Comment form data:', { facilityId, commentText }); // 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 { try {
console.log('Sending comment request...');
// Use the API client to add a status comment // Use the API client to add a status comment
const data = await window.api.addFacilityStatus(facilityId, commentText); const data = await window.api.addFacilityStatus(facilityId.toString(), statusComment);
console.log('Comment response:', data);
if (data.success) { if (data.success) {
console.log('Comment added successfully');
// Reset the form // Reset the form
this.reset(); commentForm.reset();
// Reload comments to show the new one // Reload comments to show the new one
loadFacilityComments(facilityId); this.loadFacilityComments(facilityId.toString());
} else { } else {
console.error('Comment failed:', data.error); console.error('Comment failed:', data.error);
alert(data.error || 'Failed to add comment'); alert(data.error || 'Failed to add comment');
@@ -141,33 +184,32 @@ function setupCommentFormHandler(commentForm) {
console.error('Error adding comment:', error); console.error('Error adding comment:', error);
alert('Failed to add comment: ' + error.message); alert('Failed to add comment: ' + error.message);
} finally { } finally {
this.submitting = false; commentForm.submitting = false;
} }
}); });
} },
/** /**
* Set up a single edit comment form handler * Set up a single edit comment form handler
* @param {HTMLFormElement} editCommentForm - The edit comment form element
*/ */
function setupEditCommentFormHandler(editCommentForm) { setupEditCommentFormHandler(editCommentForm) {
editCommentForm.addEventListener('submit', async function(e) { editCommentForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
// Prevent duplicate submissions // Prevent duplicate submissions
if (this.submitting) { if (editCommentForm.submitting) {
return; return;
} }
this.submitting = true; editCommentForm.submitting = true;
// Check if user is authenticated // Check if user is authenticated
if (!isAuthenticated()) { if (!this.isAuthenticated()) {
alert('You must be logged in to edit comments'); alert('You must be logged in to edit comments');
this.submitting = false; editCommentForm.submitting = false;
return; return;
} }
const formData = new FormData(this); const formData = new FormData(editCommentForm);
// Get form data // Get form data
const commentText = formData.get('editCommentText'); const commentText = formData.get('editCommentText');
@@ -193,7 +235,7 @@ function setupEditCommentFormHandler(editCommentForm) {
} }
// Reload comments to show the updated one // Reload comments to show the updated one
loadFacilityComments(facilityId); this.loadFacilityComments(facilityId);
} else { } else {
console.error('Edit comment failed:', data.error); console.error('Edit comment failed:', data.error);
alert(data.error || 'Failed to edit comment'); alert(data.error || 'Failed to edit comment');
@@ -202,18 +244,32 @@ function setupEditCommentFormHandler(editCommentForm) {
console.error('Error editing comment:', error); console.error('Error editing comment:', error);
alert('Failed to edit comment: ' + error.message); alert('Failed to edit comment: ' + error.message);
} finally { } finally {
this.submitting = false; editCommentForm.submitting = false;
} }
}); });
} },
/** /**
* Creates a comment form dynamically for authenticated users * Creates a comment form dynamically for authenticated users
* @param {string} facilityId - The facility ID
*/ */
function createCommentFormForAuthenticatedUser(facilityId) { createCommentFormForAuthenticatedUser(facilityId) {
// Check if user is authenticated // First check if auth is available
if (!isAuthenticated()) { 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 ` return `
<div class="alert alert-info mb-0"> <div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
@@ -222,10 +278,10 @@ function createCommentFormForAuthenticatedUser(facilityId) {
`; `;
} }
// Create the comment form // User is authenticated, create the comment form
return ` return `
<form id="commentForm" class="mt-3"> <form id="commentForm" class="mt-3">
<input type="hidden" id="commentFacilityId" name="facilityId" value="${escapeHtml(facilityId)}"> <input type="hidden" id="commentFacilityId" name="facilityId" value="${this.escapeHtml(facilityId)}">
<div class="mb-3"> <div class="mb-3">
<label for="commentText" class="form-label">Add a Comment</label> <label for="commentText" class="form-label">Add a Comment</label>
<textarea class="form-control" id="commentText" name="commentText" rows="3" required></textarea> <textarea class="form-control" id="commentText" name="commentText" rows="3" required></textarea>
@@ -238,28 +294,35 @@ function createCommentFormForAuthenticatedUser(facilityId) {
</div> </div>
</form> </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>
`;
}
},
/** /**
* Checks if the current user is authenticated
* @returns {boolean} True if authenticated, false otherwise
*/
function isAuthenticated() {
// Use the auth service to check authentication
return window.auth.isAuthenticated();
}
/**
* Loads facility comments from the server * Loads facility comments from the server
* @param {string} facilityId - The facility ID
*/ */
async function loadFacilityComments(facilityId) { async loadFacilityComments(facilityId) {
try { try {
console.log('Loading comments for facility:', facilityId); if (!facilityId) {
throw new Error('No facility ID provided');
}
// Ensure facilityId is a string
facilityId = facilityId.toString();
// Show loading indicator // Show loading indicator
const commentsContainer = document.getElementById('commentsContainer'); const commentsContainer = document.getElementById('commentsContainer');
if (commentsContainer) { if (!commentsContainer) {
throw new Error('Comments container not found');
}
commentsContainer.innerHTML = ` commentsContainer.innerHTML = `
<div class="text-center py-4"> <div class="text-center py-4">
<div class="spinner-border text-success" role="status"> <div class="spinner-border text-success" role="status">
@@ -268,64 +331,72 @@ async function loadFacilityComments(facilityId) {
<p class="mt-2 text-muted">Loading comments...</p> <p class="mt-2 text-muted">Loading comments...</p>
</div> </div>
`; `;
}
// Use the API client to get facility statuses // Use the API client to get facility statuses
const data = await window.api.getFacilityStatuses(facilityId); const data = await window.api.getFacilityStatuses(facilityId);
console.log('Comments loaded:', data); // 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');
}
if (data.success) {
// Render the comments // Render the comments
renderComments(data.statuses, facilityId); this.renderComments(data.statuses, facilityId);
} else {
console.error('Failed to load comments:', data.error);
if (commentsContainer) {
commentsContainer.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
Failed to load comments: ${data.error || 'Unknown error'}
</div>
`;
}
}
} catch (error) { } catch (error) {
console.error('Error loading comments:', error); console.error('Error loading comments:', error);
const commentsContainer = document.getElementById('commentsContainer'); const commentsContainer = document.getElementById('commentsContainer');
if (commentsContainer) { if (commentsContainer) {
commentsContainer.innerHTML = ` commentsContainer.innerHTML = `
<div class="alert alert-danger"> <div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i> <i class="bi bi-exclamation-triangle me-2"></i>
Error loading comments: ${error.message} ${error.message}
</div> </div>
`; `;
} }
} }
} },
/** /**
* Renders comments in the comments container * Renders comments in the comments container
* @param {Array} comments - Array of comment objects
* @param {string} facilityId - The facility ID
*/ */
function renderComments(comments, facilityId) { renderComments(comments, facilityId) {
const commentsContainer = document.getElementById('commentsContainer'); const commentsContainer = document.getElementById('commentsContainer');
if (!commentsContainer) return; if (!commentsContainer) {
console.error('Comments container not found');
return;
}
// Clear the container // Clear the container
commentsContainer.innerHTML = ''; commentsContainer.innerHTML = '';
// Add the comment form for authenticated users // Add the comment form for authenticated users
commentsContainer.innerHTML += createCommentFormForAuthenticatedUser(facilityId); 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 no comments, show a message
if (!comments || comments.length === 0) { if (!comments || comments.length === 0) {
commentsContainer.innerHTML += ` const noCommentsDiv = document.createElement('div');
<div class="alert alert-light mt-3"> noCommentsDiv.className = 'alert alert-light mt-3';
noCommentsDiv.innerHTML = `
<i class="bi bi-chat-dots me-2"></i> <i class="bi bi-chat-dots me-2"></i>
No comments yet. Be the first to add a comment! No comments yet. Be the first to add a comment!
</div>
`; `;
commentsContainer.appendChild(noCommentsDiv);
return; return;
} }
@@ -338,18 +409,8 @@ function renderComments(comments, facilityId) {
const commentElement = document.createElement('div'); const commentElement = document.createElement('div');
commentElement.className = 'comment-item card mb-3 border-0 shadow-sm'; commentElement.className = 'comment-item card mb-3 border-0 shadow-sm';
// Format the date
const date = new Date(comment.timestamp);
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Check if the current user is the comment author or an admin // Check if the current user is the comment author or an admin
const canEdit = isAdmin() || isCurrentUser(comment.username); const canEdit = this.isAdmin() || this.isCurrentUser(comment.username);
commentElement.innerHTML = ` commentElement.innerHTML = `
<div class="card-body"> <div class="card-body">
@@ -359,8 +420,7 @@ function renderComments(comments, facilityId) {
<i class="bi bi-person-fill text-secondary"></i> <i class="bi bi-person-fill text-secondary"></i>
</div> </div>
<div> <div>
<h6 class="mb-0 fw-bold">${escapeHtml(comment.username)}</h6> <h6 class="mb-0 fw-bold">${this.escapeHtml(comment.username)}</h6>
<small class="text-muted">${formattedDate}</small>
</div> </div>
</div> </div>
${canEdit ? ` ${canEdit ? `
@@ -370,12 +430,12 @@ function renderComments(comments, facilityId) {
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#editCommentModal" data-comment-id="${comment.id}" data-comment-text="${escapeHtml(comment.comment)}"> <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 <i class="bi bi-pencil me-2"></i>Edit
</button> </button>
</li> </li>
<li> <li>
<button class="dropdown-item text-danger" type="button" onclick="deleteComment('${comment.id}', '${facilityId}')"> <button class="dropdown-item text-danger" type="button" onclick="CommentsManager.deleteComment('${comment.id}', '${facilityId}')">
<i class="bi bi-trash me-2"></i>Delete <i class="bi bi-trash me-2"></i>Delete
</button> </button>
</li> </li>
@@ -383,7 +443,7 @@ function renderComments(comments, facilityId) {
</div> </div>
` : ''} ` : ''}
</div> </div>
<p class="mb-0">${escapeHtml(comment.comment)}</p> <p class="mb-0">${this.escapeHtml(comment.statusComment)}</p>
</div> </div>
`; `;
@@ -391,38 +451,24 @@ function renderComments(comments, facilityId) {
}); });
commentsContainer.appendChild(commentsList); commentsContainer.appendChild(commentsList);
},
// Re-initialize the comment form handler /**
const commentForm = document.getElementById('commentForm');
if (commentForm) {
setupCommentFormHandler(commentForm);
}
}
/**
* Deletes a comment * Deletes a comment
* @param {string} commentId - The comment ID
* @param {string} facilityId - The facility ID
*/ */
async function deleteComment(commentId, facilityId) { async deleteComment(commentId, facilityId) {
// Confirm deletion // Confirm deletion
if (!confirm('Are you sure you want to delete this comment?')) { if (!confirm('Are you sure you want to delete this comment?')) {
return; return;
} }
try { try {
console.log('Deleting comment:', commentId, 'for facility:', facilityId);
// Use the API client to delete a status comment // Use the API client to delete a status comment
const data = await window.api.deleteFacilityStatus(commentId, facilityId); const data = await window.api.deleteFacilityStatus(commentId, facilityId);
console.log('Delete comment response:', data);
if (data.success) { if (data.success) {
console.log('Comment deleted successfully');
// Reload comments to reflect the deletion // Reload comments to reflect the deletion
loadFacilityComments(facilityId); this.loadFacilityComments(facilityId);
} else { } else {
console.error('Delete comment failed:', data.error); console.error('Delete comment failed:', data.error);
alert(data.error || 'Failed to delete comment'); alert(data.error || 'Failed to delete comment');
@@ -431,33 +477,27 @@ async function deleteComment(commentId, facilityId) {
console.error('Error deleting comment:', error); console.error('Error deleting comment:', error);
alert('Failed to delete comment: ' + error.message); alert('Failed to delete comment: ' + error.message);
} }
} },
/** /**
* Checks if the current user is an admin * Checks if the current user is an admin
* @returns {boolean} True if admin, false otherwise
*/ */
function isAdmin() { isAdmin() {
// Use the auth service to check if user is admin return window.auth && window.auth.isAdmin();
return window.auth.isAdmin(); },
}
/** /**
* Checks if the given username matches the current user * Checks if the given username matches the current user
* @param {string} username - The username to check
* @returns {boolean} True if current user, false otherwise
*/ */
function isCurrentUser(username) { isCurrentUser(username) {
const user = window.auth.getUser(); const user = window.auth && window.auth.getUser();
return user && user.username === username; return user && user.username === username;
} },
/** /**
* Safely escapes HTML special characters to prevent XSS attacks * Safely escapes HTML special characters to prevent XSS attacks
* @param {*} unsafe - The value to escape
* @returns {string} The escaped string
*/ */
function escapeHtml(unsafe) { escapeHtml(unsafe) {
if (unsafe === null || unsafe === undefined) { if (unsafe === null || unsafe === undefined) {
return ''; return '';
} }
@@ -468,4 +508,40 @@ function escapeHtml(unsafe) {
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .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;

View File

@@ -5,8 +5,7 @@
*/ */
function initialiseFacilityData(data, force = false) { function initialiseFacilityData(data, force = false) {
// Only prevent multiple initializations if not forcing // Only prevent multiple initializations if not forcing
if (!force && isInitialized) { if (!force && isinitialised) {
console.debug('Facility data already initialized, skipping...');
return; return;
} }
@@ -17,11 +16,13 @@ function initialiseFacilityData(data, force = false) {
} }
// Store the data in sessionStorage for persistence // Store the data in sessionStorage for persistence
sessionStorage.setItem('facilityData', JSON.stringify(data)); sessionStorage.setItem('facilityData', JSON.stringify(data));
// Ensure table exists
// Check if we're on the map page
const isMapPage = window.location.pathname.includes('map.php');
if (!isMapPage) {
// Only try to initialise table if we're not on the map page
const table = document.querySelector('#facilityTable'); const table = document.querySelector('#facilityTable');
if (!table) { if (!table) {
console.error('Table not found in DOM. Available elements:',
Array.from(document.querySelectorAll('table')).map(t => t.id || 'no-id'));
throw new Error('Facility table not found in DOM'); throw new Error('Facility table not found in DOM');
} }
// Clear existing table content // Clear existing table content
@@ -29,28 +30,36 @@ function initialiseFacilityData(data, force = false) {
if (tbody) { if (tbody) {
tbody.innerHTML = ''; tbody.innerHTML = '';
} else { } else {
console.warn('No tbody found in table, creating one');
const newTbody = document.createElement('tbody'); const newTbody = document.createElement('tbody');
table.appendChild(newTbody); table.appendChild(newTbody);
} }
// Initialize filteredData with all data // initialise filteredData with all data
filteredData = data; filteredData = data;
// Calculate total pages // Calculate total pages
totalPages = Math.ceil(filteredData.length / itemsPerPage); totalPages = Math.ceil(filteredData.length / itemsPerPage);
// Set current page to 1 // Set current page to 1
currentPage = 1; currentPage = 1;
// Update table with paginated data // Reset sorting state
updateTableWithPagination(); currentSortField = null;
currentSortOrder = null;
// Set up table controls (sorting and filtering) // Set up table controls (sorting and filtering)
setupTableControls(); setupTableControls();
// Mark as initialized // Update table with paginated data
isInitialized = true; updateTableWithPagination();
}
// Mark as initialised
isinitialised = true;
} catch (error) { } catch (error) {
error_log('Error initialising facility data:', error); console.error('Error initialising facility data:', error);
// Don't throw error if we're on map page, as table errors are expected
if (!window.location.pathname.includes('map.php')) {
throw error;
}
} }
} }
@@ -68,6 +77,55 @@ function renderFacilityTable(data) {
// Clear existing table content // Clear existing table content
tbody.innerHTML = ''; tbody.innerHTML = '';
// Check if user is admin
const userIsAdmin = isAdmin();
// Set up table headers first
const tableHeaderRow = document.getElementById('tableHeaderRow');
if (tableHeaderRow) {
// Define header configuration
const headers = [
{ field: 'title', label: 'Title', width: '17%' },
{ field: 'category', label: 'Category', width: '11%', center: true },
{ field: 'description', label: 'Description', width: '27%' },
{ field: 'address', label: 'Address', width: '20%' },
{ field: 'coordinates', label: 'Coordinates', width: '10%', center: true },
{ field: 'contributor', label: 'Contributor', width: '7%', center: true },
{ field: 'actions', label: 'Actions', width: '8%', center: true, sortable: false }
];
// Clear existing headers
tableHeaderRow.innerHTML = '';
// Create header cells
headers.forEach(header => {
const th = document.createElement('th');
th.className = 'fw-semibold' + (header.center ? ' text-center' : '');
th.style.width = header.width;
if (header.sortable !== false) {
th.classList.add('sortable');
th.style.cursor = 'pointer';
th.dataset.field = header.field;
// Create header content with sort indicator
th.innerHTML = `
<div class="d-flex align-items-center gap-1 ${header.center ? 'justify-content-center' : ''}">
<span>${header.label}</span>
<i class="bi bi-arrow-down-up sort-icon"></i>
</div>
`;
// Add click handler
th.addEventListener('click', () => handleHeaderClick(header.field));
} else {
th.textContent = header.label;
}
tableHeaderRow.appendChild(th);
});
}
// Render each row // Render each row
data.forEach((facility, index) => { data.forEach((facility, index) => {
if (!facility) return; if (!facility) return;
@@ -82,9 +140,12 @@ function renderFacilityTable(data) {
// Create category badge with color based on category // Create category badge with color based on category
const categoryClass = getCategoryColorClass(facility.category); const categoryClass = getCategoryColorClass(facility.category);
row.innerHTML = ` // Start building the row HTML
<td class="d-none">${escapeHtml(facility.id)}</td> let rowHtml = '';
<td class="fw-medium align-middle" style="width: 15%;">
// Add the rest of the columns
rowHtml += `
<td class="fw-medium align-middle" style="width: 17%;">
<div class="d-flex align-items-center h-100"> <div class="d-flex align-items-center h-100">
<div class="facility-icon me-2 rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 28px; height: 28px; min-width: 28px;"> <div class="facility-icon me-2 rounded-circle bg-light d-flex align-items-center justify-content-center" style="width: 28px; height: 28px; min-width: 28px;">
<i class="${getFacilityIcon(facility.category)} text-${categoryClass}"></i> <i class="${getFacilityIcon(facility.category)} text-${categoryClass}"></i>
@@ -92,14 +153,14 @@ function renderFacilityTable(data) {
<span class="text-truncate" style="max-width: calc(100% - 35px);">${escapeHtml(facility.title)}</span> <span class="text-truncate" style="max-width: calc(100% - 35px);">${escapeHtml(facility.title)}</span>
</div> </div>
</td> </td>
<td class="text-center align-middle" style="width: 10%;"> <td class="text-center align-middle" style="width: 11%;">
<div class="d-flex align-items-center justify-content-center h-100"> <div class="d-flex align-items-center justify-content-center h-100">
<span class="badge bg-${categoryClass} bg-opacity-10 text-${categoryClass} px-2 py-1 rounded-pill"> <span class="badge bg-${categoryClass} bg-opacity-10 text-${categoryClass} px-2 py-1 rounded-pill">
${escapeHtml(facility.category)} ${escapeHtml(facility.category)}
</span> </span>
</div> </div>
</td> </td>
<td class="align-middle" style="width: 25%;"> <td class="align-middle" style="width: 27%;">
<div class="description-container d-flex flex-column justify-content-center"> <div class="description-container d-flex flex-column justify-content-center">
<div class="cell-content" data-full-text="${escapeHtml(facility.description)}"> <div class="cell-content" data-full-text="${escapeHtml(facility.description)}">
${escapeHtml(facility.description)} ${escapeHtml(facility.description)}
@@ -118,16 +179,15 @@ function renderFacilityTable(data) {
</div> </div>
</div> </div>
</td> </td>
<td class="small fw-medium text-center align-middle" style="width: 8%;" hidden>${escapeHtml(facility.postcode)}</td> <td class="small text-nowrap text-center align-middle" style="width: 10%;">
<td class="small text-nowrap text-center align-middle" style="width: 12%;">
<span class="badge bg-light text-dark border"> <span class="badge bg-light text-dark border">
${escapeHtml(coordinates)} ${escapeHtml(coordinates)}
</span> </span>
</td> </td>
<td class="small text-center align-middle" style="width: 8%;">${escapeHtml(facility.contributor)}</td> <td class="small text-center align-middle" style="width: 5%;">${escapeHtml(facility.contributor)}</td>
<td class="text-center align-middle" style="width: 10%;"> <td class="text-center align-middle" style="width: 8%;">
<div class="d-flex justify-content-center gap-1"> <div class="d-flex justify-content-center gap-1">
${isAdmin() ? ` ${userIsAdmin ? `
<button type="button" class="btn btn-sm btn-outline-primary update-btn rounded-circle d-flex align-items-center justify-content-center" style="width: 30px; height: 30px;" data-bs-toggle="modal" data-bs-target="#updateModal" data-facility-id="${facility.id}" title="Edit"> <button type="button" class="btn btn-sm btn-outline-primary update-btn rounded-circle d-flex align-items-center justify-content-center" style="width: 30px; height: 30px;" data-bs-toggle="modal" data-bs-target="#updateModal" data-facility-id="${facility.id}" title="Edit">
<span class="bi bi-pen-fill"></span> <span class="bi bi-pen-fill"></span>
</button> </button>
@@ -142,14 +202,16 @@ function renderFacilityTable(data) {
</td> </td>
`; `;
row.innerHTML = rowHtml;
tbody.appendChild(row); tbody.appendChild(row);
}); });
// If no data, show a message // If no data, show a message
if (data.length === 0) { if (data.length === 0) {
const emptyRow = document.createElement('tr'); const emptyRow = document.createElement('tr');
const colSpan = userIsAdmin ? 8 : 7; // Adjust colspan based on number of columns
emptyRow.innerHTML = ` emptyRow.innerHTML = `
<td colspan="9" class="text-center py-4 text-muted"> <td colspan="${colSpan}" class="text-center py-4 text-muted">
<div class="d-flex flex-column align-items-center"> <div class="d-flex flex-column align-items-center">
<span class="bi bi-inbox fs-2 mb-2"></span> <span class="bi bi-inbox fs-2 mb-2"></span>
<p class="mb-0">No facilities found</p> <p class="mb-0">No facilities found</p>
@@ -158,6 +220,9 @@ function renderFacilityTable(data) {
`; `;
tbody.appendChild(emptyRow); tbody.appendChild(emptyRow);
} }
// Update sort indicators
updateSortIndicators();
} catch (error) { } catch (error) {
error_log('Error in renderFacilityTable:', error); error_log('Error in renderFacilityTable:', error);
} }
@@ -204,10 +269,15 @@ function formatAddress(facility) {
* @returns {boolean} True if user is admin, false otherwise * @returns {boolean} True if user is admin, false otherwise
*/ */
function isAdmin() { function isAdmin() {
console.log('Checking admin status...');
// Check if auth service is available and has user data // Check if auth service is available and has user data
if (window.auth && window.auth.getUser()) { if (auth && auth.getUser()) {
const authUser = window.auth.getUser(); const authUser = auth.getUser();
console.log('Auth service user data:', authUser); console.log('Auth service user data:', authUser);
console.log('Auth service accessLevel:', authUser.accessLevel);
console.log('Auth service isAdmin check:', authUser.accessLevel === 1 || authUser.accessLevel === 0);
if (authUser && (authUser.accessLevel === 1 || authUser.accessLevel === 0)) { if (authUser && (authUser.accessLevel === 1 || authUser.accessLevel === 0)) {
console.log('User is admin according to auth service'); console.log('User is admin according to auth service');
return true; return true;
@@ -217,17 +287,26 @@ function isAdmin() {
// Fallback to localStorage // Fallback to localStorage
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
console.log('Checking admin status from localStorage:', user); console.log('Checking admin status from localStorage:', user);
const isAdminUser = user && (user.accessLevel === 1 || user.accessLevel === 0); console.log('localStorage accessLevel:', user.accessLevel);
console.log('Is admin according to localStorage:', isAdminUser); console.log('localStorage isAdmin check:', user.accessLevel === 1 || user.accessLevel === 0);
const isAdminUser = user && (user.accessLevel === 1 || user.accessLevel === 0);
console.log('Final isAdmin result:', isAdminUser);
return isAdminUser; return isAdminUser;
} }
/** /**
* Checks if the current user is authenticated * (helper function) Checks if the current user is authenticated
* @returns {boolean} True if authenticated, false otherwise * @returns {boolean} True if authenticated, false otherwise
*/ */
function isAuthenticated() { function isAuthenticated() {
// Check if auth service is available
if (auth) {
return auth.isAuthenticated();
}
// Fallback to localStorage
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
return !!token; return !!token;
} }
@@ -242,15 +321,19 @@ let filteredData = [];
let paginationHandler = null; let paginationHandler = null;
// Add initialization state tracking // Add initialization state tracking
let isInitialized = false; let isinitialised = false;
// Initialize modals once // initialise modals once
let updateModal, deleteModal, createModal; let updateModal, deleteModal, createModal;
let formHandlersInitialized = false; let formHandlersinitialised = false;
// Add sorting state variables
let currentSortField = null;
let currentSortOrder = null; // null = unsorted, 'asc' = ascending, 'desc' = descending
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize modals once // initialise modals once
const modals = document.querySelectorAll('.modal'); const modals = document.querySelectorAll('.modal');
modals.forEach((modal, index) => { modals.forEach((modal, index) => {
if (modal.id === 'updateModal') { if (modal.id === 'updateModal') {
@@ -347,15 +430,15 @@ document.addEventListener('DOMContentLoaded', function() {
// Set up form handlers with a small delay to ensure DOM is fully loaded // Set up form handlers with a small delay to ensure DOM is fully loaded
setTimeout(() => { setTimeout(() => {
if (!formHandlersInitialized) { if (!formHandlersinitialised) {
console.log('Setting up form handlers...'); console.log('Setting up form handlers...');
setupFormHandlers(); setupFormHandlers();
formHandlersInitialized = true; formHandlersinitialised = true;
} }
}, 100); }, 100);
// Initialize facility data if not already initialized // initialise facility data if not already initialised
if (!isInitialized) { if (!isinitialised) {
const storedData = sessionStorage.getItem('facilityData'); const storedData = sessionStorage.getItem('facilityData');
if (storedData) { if (storedData) {
try { try {
@@ -366,6 +449,23 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
} }
// Add CSS styles for sort indicators
const style = document.createElement('style');
style.textContent = `
.sortable:hover {
background-color: rgba(25, 135, 84, 0.1);
}
.sort-icon {
opacity: 0.5;
font-size: 0.8em;
}
.sortable:hover .sort-icon,
.text-success .sort-icon {
opacity: 1;
}
`;
document.head.appendChild(style);
}); });
// Handle create form submission // Handle create form submission
@@ -394,9 +494,11 @@ function setupFormHandlers() {
const formData = new FormData(this); const formData = new FormData(this);
// Set the contributor to the current user's username // Set the contributor to the current user's username
formData.set('contCreate', JSON.parse(localStorage.getItem('user'))?.username); formData.set('contCreate', JSON.parse(localStorage.getItem('user'))?.username);
// Set the action to 'create'
formData.set('action', 'create');
try { try {
// Use authFetch instead of regular fetch // Use auth.fetchAuth for authenticated requests
const response = await window.authFetch('/facilitycontroller.php', { const response = await auth.fetchAuth('/facilitycontroller.php', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -476,25 +578,35 @@ function setupFormHandlers() {
const formData = new FormData(this); const formData = new FormData(this);
// Create a new FormData with the correct field names for the server // Create a new FormData with the correct field names for the server
// This is due to the contributor field being disabled in the form
// disallowing it to be included in the form data
const serverFormData = new FormData(); const serverFormData = new FormData();
serverFormData.append('action', 'update'); serverFormData.append('action', 'update');
// Copy all fields from the form to the server form data // Map form fields to server field names
for (const [key, value] of formData.entries()) { const fieldMappings = {
serverFormData.append(key, value); 'idUpdate': 'id',
} 'titlUpdate': 'title',
'cateUpdate': 'category',
'descUpdate': 'description',
'hnumUpdate': 'houseNumber',
'strtUpdate': 'streetName',
'cntyUpdate': 'county',
'townUpdate': 'town',
'postUpdate': 'postcode',
'lngUpdate': 'lng',
'latUpdate': 'lat',
'contUpdate': 'contributor'
};
// Ensure the contributor field is included (it might be disabled in the form) // Copy and transform fields from the form to the server form data
const contUpdateField = document.getElementById('contUpdate'); for (const [key, value] of formData.entries()) {
if (contUpdateField) { if (fieldMappings[key]) {
serverFormData.append('contUpdate', contUpdateField.value); serverFormData.append(fieldMappings[key], value);
}
} }
try { try {
// Use authFetch instead of regular fetch // Use auth.fetchAuth for authenticated requests
const response = await window.authFetch('/facilitycontroller.php', { const response = await auth.fetchAuth('/facilitycontroller.php', {
method: 'POST', method: 'POST',
body: serverFormData body: serverFormData
}); });
@@ -582,19 +694,19 @@ function setupFormHandlers() {
try { try {
// Check if token is valid // Check if token is valid
if (!window.auth) { if (!auth) {
throw new Error('Auth service not available'); throw new Error('Auth service not available');
} }
// Validate token with server before proceeding // Validate token with server before proceeding
console.log('Validating token with server...'); console.log('Validating token with server...');
const isValid = await window.auth.validateToken(); const isValid = await auth.validateToken();
if (!isValid) { if (!isValid) {
throw new Error('Authentication token is invalid or expired'); throw new Error('Authentication token is invalid or expired');
} }
// Get token after validation to ensure it's fresh // Get token after validation to ensure it's fresh
const token = window.auth.getToken(); const token = auth.getToken();
console.log('Using token for delete request:', token); console.log('Using token for delete request:', token);
if (!token) { if (!token) {
@@ -602,16 +714,16 @@ function setupFormHandlers() {
} }
// Decode token to check payload // Decode token to check payload
if (window.auth.parseJwt) { if (auth.parseJwt) {
const payload = window.auth.parseJwt(token); const payload = auth.parseJwt(token);
console.log('Token payload:', payload); console.log('Token payload:', payload);
console.log('Access level:', payload.accessLevel); console.log('Access level:', payload.accessLevel);
console.log('Is admin check:', payload.accessLevel === 0 || payload.accessLevel === 1); console.log('Is admin check:', payload.accessLevel === 0 || payload.accessLevel === 1);
} }
// Use direct fetch with manual token inclusion // Use auth.fetchAuth for authenticated requests
console.log('Sending delete request to server...'); console.log('Sending delete request to server...');
const response = await fetch('/facilitycontroller.php', { const response = await auth.fetchAuth('/facilitycontroller.php', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
@@ -760,12 +872,6 @@ function updatePaginationControls() {
paginationInfo.querySelector('span').textContent = `Showing ${startItem}-${endItem} of ${filteredData.length} facilities`; paginationInfo.querySelector('span').textContent = `Showing ${startItem}-${endItem} of ${filteredData.length} facilities`;
} }
} }
// Update facility count badge
const facilityCount = document.getElementById('facilityCount');
if (facilityCount) {
facilityCount.textContent = `${filteredData.length} facilities`;
}
} }
function createPageNumberElement(pageNum, isActive) { function createPageNumberElement(pageNum, isActive) {
@@ -821,6 +927,8 @@ function updateTableWithPagination() {
const pageData = getCurrentPageData(); const pageData = getCurrentPageData();
renderFacilityTable(pageData); renderFacilityTable(pageData);
updatePaginationControls(); updatePaginationControls();
// Update sort indicators after rendering table
updateSortIndicators();
} }
/** /**
@@ -889,14 +997,13 @@ function updateTable() {
} }
// Get current filter values // Get current filter values
const sortBy = document.getElementById('sort').value; const searchTerm = document.getElementById('searchInput').value;
const sortDir = document.getElementById('dir').value;
const filterCategory = document.getElementById('filterCat').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
// Apply filters and sorting // Apply filters and sorting
filteredData = filterData(data, filterCategory, searchTerm); filteredData = filterData(data, searchTerm);
filteredData = sortData(filteredData, sortBy, sortDir); if (currentSortField && currentSortOrder) {
filteredData = sortData(filteredData, currentSortField, currentSortOrder);
}
// Update pagination // Update pagination
totalPages = Math.ceil(filteredData.length / itemsPerPage); totalPages = Math.ceil(filteredData.length / itemsPerPage);
@@ -921,12 +1028,9 @@ function setupTableControls() {
} }
// Get control elements // Get control elements
const filterControls = document.querySelectorAll('.filter-control');
const sortControls = document.querySelectorAll('.sort-control');
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
if (!searchInput) {
if (!filterControls.length || !sortControls.length || !searchInput) { error_log('Missing search input');
error_log('Missing filter or sort controls');
return; return;
} }
@@ -937,66 +1041,142 @@ function setupTableControls() {
updateTable(); updateTable();
}); });
// Add event listeners for immediate updates // Add event listener for search input
searchInput.addEventListener('input', updateTable); searchInput.addEventListener('input', updateTable);
// Add change event listeners for select elements // Set up table headers for sorting
filterControls.forEach(control => { setupSortableHeaders();
control.addEventListener('change', updateTable);
});
sortControls.forEach(control => {
control.addEventListener('change', updateTable);
});
// Set up pagination controls // Set up pagination controls
setupPaginationControls(); setupPaginationControls();
} }
/** /**
* Filters the facility data based on current filter values * Sets up sortable table headers
*/
function setupSortableHeaders() {
const tableHeaderRow = document.getElementById('tableHeaderRow');
if (!tableHeaderRow) return;
// Define header configuration
const headers = [
{ field: 'title', label: 'Title', width: '17%' },
{ field: 'category', label: 'Category', width: '11%', center: true },
{ field: 'description', label: 'Description', width: '27%' },
{ field: 'address', label: 'Address', width: '20%' },
{ field: 'coordinates', label: 'Coordinates', width: '12%', center: true },
{ field: 'contributor', label: 'Contributor', width: '8%', center: true },
{ field: 'actions', label: 'Actions', width: '5%', center: true, sortable: false }
];
// Clear existing headers
tableHeaderRow.innerHTML = '';
// Create header cells
headers.forEach(header => {
const th = document.createElement('th');
th.className = 'fw-semibold' + (header.center ? ' text-center' : '');
th.style.width = header.width;
if (header.sortable !== false) {
th.classList.add('sortable');
th.style.cursor = 'pointer';
th.dataset.field = header.field;
// Create header content with sort indicator
th.innerHTML = `
<div class="d-flex align-items-center gap-1 ${header.center ? 'justify-content-center' : ''}">
<span>${header.label}</span>
<i class="bi bi-arrow-down-up sort-icon"></i>
</div>
`;
// Add click handler
th.addEventListener('click', () => handleHeaderClick(header.field));
} else {
th.textContent = header.label;
}
tableHeaderRow.appendChild(th);
});
// initialise sort indicators
updateSortIndicators();
}
/**
* Handles click on sortable header
* @param {string} field - The field to sort by
*/
function handleHeaderClick(field) {
console.log('Header clicked:', field); // Debug log
// Rotate through sort orders: none -> asc -> desc -> none
if (currentSortField === field) {
if (currentSortOrder === 'asc') {
currentSortOrder = 'desc';
} else if (currentSortOrder === 'desc') {
currentSortField = null;
currentSortOrder = null;
}
} else {
currentSortField = field;
currentSortOrder = 'asc';
}
console.log('New sort state:', { field: currentSortField, order: currentSortOrder }); // Debug log
// Update table
updateTable();
}
/**
* Updates sort indicators in table headers
*/
function updateSortIndicators() {
const headers = document.querySelectorAll('#tableHeaderRow th.sortable');
headers.forEach(header => {
const icon = header.querySelector('.sort-icon');
if (header.dataset.field === currentSortField) {
icon.classList.remove('bi-arrow-down-up');
icon.classList.add(currentSortOrder === 'asc' ? 'bi-arrow-up' : 'bi-arrow-down');
header.classList.add('text-success');
} else {
icon.classList.remove('bi-arrow-up', 'bi-arrow-down');
icon.classList.add('bi-arrow-down-up');
header.classList.remove('text-success');
}
});
}
/**
* Filters the facility data based on search term
* @param {Array} data - Array of facility objects * @param {Array} data - Array of facility objects
* @param {string} category - Filter category
* @param {string} searchTerm - Search term * @param {string} searchTerm - Search term
* @returns {Array} Filtered array of facility objects * @returns {Array} Filtered array of facility objects
*/ */
function filterData(data, category, searchTerm) { function filterData(data, searchTerm) {
const filtered = data.filter(facility => { const filtered = data.filter(facility => {
if (!facility) return false; if (!facility) return false;
// If no category selected or no search term, show all results // If no search term, show all results
if (!category || !searchTerm) return true; if (!searchTerm) return true;
// Get the value to search in based on the selected category // Convert search term to lowercase for case-insensitive search
let searchValue = ''; searchTerm = searchTerm.toLowerCase();
switch(category) {
case 'title':
searchValue = (facility.title || '').toLowerCase();
break;
case 'category':
searchValue = (facility.category || '').toLowerCase();
break;
case 'description':
searchValue = (facility.description || '').toLowerCase();
break;
case 'streetName':
searchValue = (facility.streetName || '').toLowerCase();
break;
case 'county':
searchValue = (facility.county || '').toLowerCase();
break;
case 'town':
searchValue = (facility.town || '').toLowerCase();
break;
case 'postcode':
searchValue = (facility.postcode || '').toLowerCase();
break;
case 'contributor':
searchValue = (facility.contributor || '').toLowerCase();
break;
}
return searchValue.includes(searchTerm.toLowerCase()); // Search across all relevant fields
return (
(facility.title || '').toLowerCase().includes(searchTerm) ||
(facility.category || '').toLowerCase().includes(searchTerm) ||
(facility.description || '').toLowerCase().includes(searchTerm) ||
(facility.streetName || '').toLowerCase().includes(searchTerm) ||
(facility.county || '').toLowerCase().includes(searchTerm) ||
(facility.town || '').toLowerCase().includes(searchTerm) ||
(facility.postcode || '').toLowerCase().includes(searchTerm) ||
(facility.contributor || '').toLowerCase().includes(searchTerm) ||
(facility.houseNumber || '').toLowerCase().includes(searchTerm)
);
}); });
return filtered; return filtered;
} }
@@ -1010,51 +1190,50 @@ function filterData(data, category, searchTerm) {
*/ */
function sortData(data, sortBy, sortDir) { function sortData(data, sortBy, sortDir) {
if (!sortBy) return data; if (!sortBy) return data;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
console.log('Sorting by:', sortBy, 'Direction:', sortDir); // Debug log
return [...data].sort((a, b) => { return [...data].sort((a, b) => {
if (!a || !b) return 0; if (!a || !b) return 0;
let valueA = ''; let valueA, valueB;
let valueB = '';
// Get the values to compare based on the selected field // Special handling for address field which is composed of multiple fields
switch (sortBy) { if (sortBy === 'address') {
case 'title': valueA = [
valueA = (a.title || '').toLowerCase(); a.houseNumber || '',
valueB = (b.title || '').toLowerCase(); a.streetName || '',
break; a.town || '',
case 'category': a.county || '',
valueA = (a.category || '').toLowerCase(); a.postcode || ''
valueB = (b.category || '').toLowerCase(); ].filter(Boolean).join(', ').toLowerCase();
break;
case 'description': valueB = [
valueA = (a.description || '').toLowerCase(); b.houseNumber || '',
valueB = (b.description || '').toLowerCase(); b.streetName || '',
break; b.town || '',
case 'streetName': b.county || '',
valueA = (a.streetName || '').toLowerCase(); b.postcode || ''
valueB = (b.streetName || '').toLowerCase(); ].filter(Boolean).join(', ').toLowerCase();
break; }
case 'county': // Special handling for coordinates field
valueA = (a.county || '').toLowerCase(); else if (sortBy === 'coordinates') {
valueB = (b.county || '').toLowerCase(); // Sort by latitude first, then longitude
break; valueA = `${parseFloat(a.lat || 0)},${parseFloat(a.lng || 0)}`;
case 'town': valueB = `${parseFloat(b.lat || 0)},${parseFloat(b.lng || 0)}`;
valueA = (a.town || '').toLowerCase(); }
valueB = (b.town || '').toLowerCase(); // Default handling for other fields
break; else {
case 'postcode': valueA = (a[sortBy] || '').toString().toLowerCase();
valueA = (a.postcode || '').toLowerCase(); valueB = (b[sortBy] || '').toString().toLowerCase();
valueB = (b.postcode || '').toLowerCase();
break;
case 'contributor':
valueA = (a.contributor || '').toLowerCase();
valueB = (b.contributor || '').toLowerCase();
break;
} }
const comparison = valueA.localeCompare(valueB); console.log('Comparing:', valueA, valueB); // Debug log
return sortDir === 'asc' ? comparison : -comparison;
// Compare the values
if (valueA < valueB) return sortDir === 'asc' ? -1 : 1;
if (valueA > valueB) return sortDir === 'asc' ? 1 : -1;
return 0;
}); });
} }
@@ -1112,86 +1291,5 @@ function getCategoryColorClass(category) {
return 'secondary'; return 'secondary';
} }
/**
* Sets up expandable content for a table row
* @param {HTMLElement} row - The table row element
*/
function setupExpandableContent(row) {
// Setup description expansion
const descriptionContent = row.querySelector('.cell-content');
const toggleBtn = row.querySelector('.toggle-content-btn');
if (descriptionContent) {
// Make description expandable on click
descriptionContent.addEventListener('click', function() {
this.classList.toggle('expanded');
// Update button text if it exists
if (toggleBtn) {
toggleBtn.querySelector('small').textContent =
this.classList.contains('expanded') ? 'Show less' : 'Show more';
}
// Ensure proper alignment when expanded
const container = this.closest('.description-container');
if (container) {
if (this.classList.contains('expanded')) {
container.classList.remove('justify-content-center');
container.classList.add('justify-content-start');
} else {
container.classList.remove('justify-content-start');
container.classList.add('justify-content-center');
}
}
});
// Setup toggle button if it exists
if (toggleBtn) {
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const content = this.closest('.description-container').querySelector('.cell-content');
content.classList.toggle('expanded');
this.querySelector('small').textContent =
content.classList.contains('expanded') ? 'Show less' : 'Show more';
// Ensure proper alignment when expanded
const container = content.closest('.description-container');
if (container) {
if (content.classList.contains('expanded')) {
container.classList.remove('justify-content-center');
container.classList.add('justify-content-start');
} else {
container.classList.remove('justify-content-start');
container.classList.add('justify-content-center');
}
}
});
}
}
// Setup address expansion
const addressContent = row.querySelector('.address-content');
if (addressContent) {
addressContent.addEventListener('click', function() {
this.classList.toggle('expanded');
// Ensure proper alignment when expanded
const container = this.closest('.d-flex');
if (container) {
if (this.classList.contains('expanded')) {
container.classList.remove('align-items-center');
container.classList.add('align-items-start');
} else {
container.classList.remove('align-items-start');
container.classList.add('align-items-center');
}
}
});
}
}
// Export the initialization function // Export the initialization function
window.initializeFacilityData = initialiseFacilityData; window.initialiseFacilityData = initialiseFacilityData;

705
public/js/mapHandler.js Normal file
View File

@@ -0,0 +1,705 @@
/**
* Map Handler for EcoBuddy
* Handles map initialization, postcode validation, and facility display
*/
// initialise map variables
let map = null;
let markers = [];
let circle = null;
let facilities = [];
let currentPostcode = null;
let currentRadius = 10; // Default radius in miles
// initialise map on document load
document.addEventListener('DOMContentLoaded', function() {
// initialise the map centered on UK
map = L.map('map', {
scrollWheelZoom: true, // Enable scroll wheel zoom
zoomControl: true // Show zoom controls
}).setView([54.5, -2], 6);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Get facilities data from sessionStorage
facilities = JSON.parse(sessionStorage.getItem('facilityData') || '[]');
// Add location found handler
map.on('locationfound', function(e) {
try {
const { lat, lng } = e.latlng;
// Update the map directly with the coordinates
updateMapLocation({ lat, lng }, currentRadius);
// Remove overlay once we have a valid location
const overlay = document.getElementById('mapOverlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Get postcode from coordinates
fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}`)
.then(response => response.json())
.then(data => {
if (data.status === 200 && data.result && data.result.length > 0) {
const postcode = data.result[0].postcode;
const postcodeInput = document.getElementById('postcode');
if (postcodeInput) {
postcodeInput.value = postcode;
}
}
})
.catch(error => {
console.error('Error getting postcode:', error);
});
} catch (error) {
console.error('Error processing location:', error);
alert('Error getting your location: ' + error.message);
}
});
// Add location error handler
map.on('locationerror', function(e) {
console.error('Geolocation error:', e);
let message = 'Error getting your location: ';
switch(e.code) {
case 1: // PERMISSION_DENIED
message += 'Please enable location access in your browser settings.';
break;
case 2: // POSITION_UNAVAILABLE
message += 'Location information is unavailable.';
break;
case 3: // TIMEOUT
message += 'Location request timed out.';
break;
default:
message += 'An unknown error occurred.';
}
alert(message);
});
// Set up form handlers
setupFormHandlers();
// Set up search handler from header
setupHeaderSearchHandler();
});
/**
* Set up form handlers for postcode and radius inputs
*/
function setupFormHandlers() {
const postcodeForm = document.getElementById('postcodeForm');
const radiusSelect = document.getElementById('radius');
if (postcodeForm) {
// Add geolocation functionality to the search button
const searchButton = postcodeForm.querySelector('button[type="submit"]');
if (searchButton) {
searchButton.onclick = (e) => {
// If the postcode input is empty, use geolocation
const postcodeInput = document.getElementById('postcode');
if (!postcodeInput.value.trim()) {
e.preventDefault();
map.locate({
setView: false,
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
});
}
};
}
postcodeForm.addEventListener('submit', async function(e) {
e.preventDefault();
const postcode = document.getElementById('postcode').value;
const radius = parseFloat(document.getElementById('radius').value);
// Show loading state
const submitButton = this.querySelector('button[type="submit"]');
submitButton.disabled = true;
// Validate postcode format first
if (!isValidPostcode(postcode)) {
submitButton.disabled = false;
submitButton.innerHTML = originalButtonContent;
alert('Please enter a valid UK postcode');
return;
}
try {
// Get coordinates for postcode
const coords = await getPostcodeCoordinates(postcode);
if (!coords) {
throw new Error('Could not find coordinates for this postcode');
}
// Update map with new location and radius
updateMapLocation(coords, radius);
// Remove overlay once we have a valid postcode
const overlay = document.getElementById('mapOverlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Store current postcode
currentPostcode = postcode;
currentRadius = radius;
} catch (error) {
console.error('Error processing postcode:', error);
alert(error.message || 'Error processing postcode');
} finally {
// Always reset button state
submitButton.disabled = false;
}
});
}
if (radiusSelect) {
radiusSelect.addEventListener('change', async function(e) {
e.preventDefault();
const postcode = document.getElementById('postcode').value;
const coords = await getPostcodeCoordinates(postcode);
const radius = parseFloat(this.value);
updateMapLocation(coords, radius);
});
}
}
/**
* Validate UK postcode format
* @param {string} postcode - The postcode to validate
* @returns {boolean} True if valid, false otherwise
*/
function isValidPostcode(postcode) {
// Basic UK postcode regex
const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}$/i;
return postcodeRegex.test(postcode.trim());
}
/**
* Get coordinates for a UK postcode using postcodes.io API
* @param {string} postcode - The postcode to geocode
* @returns {Promise<{lat: number, lng: number}>} The coordinates
*/
async function getPostcodeCoordinates(postcode) {
try {
const response = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(postcode)}`);
if (!response.ok) {
throw new Error('Postcode not found');
}
const data = await response.json();
if (data.status === 200 && data.result) {
return {
lat: data.result.latitude,
lng: data.result.longitude
};
}
throw new Error('Invalid response from postcode API');
} catch (error) {
console.error('Error getting postcode coordinates:', error);
throw error;
}
}
/**
* Update map location and display facilities within radius
* @param {Object} coords - The coordinates to center on (null to use existing)
* @param {number} radius - The radius in miles
*/
function updateMapLocation(coords, radius) {
// Clear existing markers and circle
clearMapOverlays();
// Get center coordinates (either new or existing)
const center = coords || map.getCenter();
// Convert radius from miles to meters (1 mile = 1609.34 meters)
const radiusMeters = radius * 1609.34;
// Add circle for radius
circle = L.circle([center.lat, center.lng], {
color: '#198754',
fillColor: '#198754',
fillOpacity: 0.1,
radius: radiusMeters
}).addTo(map);
// Find facilities within radius
const facilitiesInRange = findFacilitiesInRange(center, radius);
// Add markers for facilities
facilitiesInRange.forEach(facility => {
const marker = L.marker([facility.lat, facility.lng])
.bindPopup(createPopupContent(facility))
.addTo(map);
markers.push(marker);
});
// Fit map bounds to circle
map.fitBounds(circle.getBounds());
// Update facility list
updateFacilityList(facilitiesInRange);
}
/**
* Clear all markers and circle from map
*/
function clearMapOverlays() {
// Clear markers
markers.forEach(marker => marker.remove());
markers = [];
// Clear circle
if (circle) {
circle.remove();
circle = null;
}
}
/**
* Find facilities within specified radius of center point
* @param {Object} center - The center coordinates
* @param {number} radius - The radius in miles
* @returns {Array} Array of facilities within range
*/
function findFacilitiesInRange(center, radius) {
return facilities.filter(facility => {
const distance = calculateDistance(
center.lat,
center.lng,
parseFloat(facility.lat),
parseFloat(facility.lng)
);
return distance <= radius;
});
}
/**
* Calculate distance between two points using Haversine formula
* @param {number} lat1 - Latitude of first point
* @param {number} lon1 - Longitude of first point
* @param {number} lat2 - Latitude of second point
* @param {number} lon2 - Longitude of second point
* @returns {number} Distance in miles
*/
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 3959; // Earth's radius in miles
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
/**
* Convert degrees to radians
* @param {number} degrees - Value in degrees
* @returns {number} Value in radians
*/
function toRad(degrees) {
return degrees * (Math.PI / 180);
}
/**
* Create popup content for facility marker
* @param {Object} facility - The facility data
* @returns {string} HTML content for popup
*/
function createPopupContent(facility) {
const isAuthenticated = window.auth && window.auth.isAuthenticated();
return `
<div class="facility-popup">
<h6 class="mb-1">${escapeHtml(facility.title)}</h6>
<p class="mb-1 small">
<span class="badge bg-${getCategoryColorClass(facility.category)} bg-opacity-10 text-${getCategoryColorClass(facility.category)}">
${escapeHtml(facility.category)}
</span>
</p>
<p class="mb-2 small">${escapeHtml(facility.description)}</p>
<p class="mb-2 small">
<strong>Address:</strong><br>
${escapeHtml(formatAddress(facility))}
</p>
<p class="mb-0 small">
<strong>Added by:</strong> ${escapeHtml(facility.contributor)}
</p>
${isAuthenticated ? `
<div class="comment-form">
<form onsubmit="return handleCommentSubmit(event, ${facility.id})">
<div class="mb-2">
<textarea class="form-control form-control-sm"
placeholder="Add a comment..."
required
rows="2"></textarea>
</div>
<div class="d-flex justify-content-between align-items-center">
<button type="submit" class="btn btn-sm btn-success">
<i class="bi bi-chat-dots me-1"></i>Add Comment
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openCommentsModal('${facility.id}')">
<i class="bi bi-info-circle me-1"></i>View All Comments
</button>
</div>
</form>
</div>
` : `
<div class="comment-form">
<div class="alert alert-light mb-0 small">
<i class="bi bi-info-circle me-1"></i>
Please <a href="#" data-bs-toggle="modal" data-bs-target="#loginModal">login</a> to add comments
</div>
</div>
`}
</div>
`;
}
/**
* Open the comments modal for a facility
* @param {string} facilityId - The facility ID
*/
function openCommentsModal(facilityId) {
// Find the facility
const facility = facilities.find(f => f.id === parseInt(facilityId));
if (!facility) {
console.error('Facility not found:', facilityId);
return;
}
// Get the modal
const modal = document.getElementById('statusModal');
if (!modal) {
console.error('Status modal not found');
return;
}
// Set the facility ID on the modal
modal.setAttribute('data-facility-id', facilityId);
// Set the facility ID in the comment form
const facilityIdInput = modal.querySelector('#commentFacilityId');
if (facilityIdInput) {
facilityIdInput.value = facilityId;
}
// Show the modal
const modalInstance = new bootstrap.Modal(modal);
modalInstance.show();
// Load the comments using CommentsManager
CommentsManager.loadFacilityComments(facilityId);
}
/**
* Handle comment form submission
* @param {Event} event - The form submit event
* @param {number} facilityId - The facility ID
* @returns {boolean} False to prevent form submission
*/
async function handleCommentSubmit(event, facilityId) {
event.preventDefault();
// Check authentication
if (!window.auth || !window.auth.isAuthenticated()) {
alert('You must be logged in to add comments');
return false;
}
const form = event.target;
const textarea = form.querySelector('textarea');
const submitButton = form.querySelector('button[type="submit"]');
const originalButtonContent = submitButton.innerHTML;
// Show loading state
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Adding...</span>
`;
try {
// Add the comment using the API
const response = await window.api.addFacilityStatus(
facilityId.toString(), // Ensure facilityId is a string
textarea.value
);
if (response.success) {
// Clear the textarea
textarea.value = '';
// Show success message
const successMessage = document.createElement('div');
successMessage.className = 'alert alert-success mt-2 mb-0 py-2 small';
successMessage.innerHTML = `
<i class="bi bi-check-circle me-1"></i>
Comment added successfully
`;
form.appendChild(successMessage);
// Remove success message after 3 seconds
setTimeout(() => {
successMessage.remove();
// Open the comments modal to show the new comment
openCommentsModal(facilityId);
}, 3000);
} else {
throw new Error(response.error || 'Failed to add comment');
}
} catch (error) {
console.error('Error adding comment:', error);
alert('Error adding comment: ' + error.message);
} finally {
// Reset button state
submitButton.disabled = false;
submitButton.innerHTML = originalButtonContent;
}
return false;
}
/**
* Update facility list display
* @param {Array} facilities - Array of facilities to display
*/
function updateFacilityList(facilities) {
const listElement = document.getElementById('facilityList');
if (!listElement) return;
listElement.innerHTML = '';
facilities.forEach(facility => {
const distance = calculateDistance(
circle.getLatLng().lat,
circle.getLatLng().lng,
parseFloat(facility.lat),
parseFloat(facility.lng)
);
const item = document.createElement('div');
item.className = 'list-group-item list-group-item-action';
item.innerHTML = `
<div class="d-flex w-100 justify-content-between align-items-start">
<div>
<h6 class="mb-1">${escapeHtml(facility.title)}</h6>
<p class="mb-1 small">
<span class="badge bg-${getCategoryColorClass(facility.category)} bg-opacity-10 text-${getCategoryColorClass(facility.category)}">
${escapeHtml(facility.category)}
</span>
</p>
<small class="text-muted">
<i class="bi bi-geo-alt me-1"></i>${distance.toFixed(1)} miles away
</small>
</div>
<button class="btn btn-sm btn-outline-success" onclick="showFacilityDetails('${facility.id}')">
<i class="bi bi-info-circle"></i>
</button>
</div>
`;
listElement.appendChild(item);
});
}
/**
* Set up header search handler
*/
function setupHeaderSearchHandler() {
const searchInput = document.querySelector('input#searchInput');
const filterCat = document.querySelector('select#filterCat');
if (searchInput && filterCat) {
const handleSearch = () => {
const searchTerm = searchInput.value.toLowerCase();
const filterCategory = filterCat.value;
if (!currentPostcode) return; // Only filter if map is active
// Get all facilities in current radius
const center = circle ? circle.getLatLng() : null;
if (!center) return;
const facilitiesInRange = findFacilitiesInRange(center, currentRadius);
// Filter facilities based on search term and category
const filteredFacilities = facilitiesInRange.filter(facility => {
if (!facility) return false;
if (filterCategory && searchTerm) {
let searchValue = '';
switch(filterCategory) {
case 'title':
searchValue = (facility.title || '').toLowerCase();
break;
case 'category':
searchValue = (facility.category || '').toLowerCase();
break;
case 'description':
searchValue = (facility.description || '').toLowerCase();
break;
case 'streetName':
searchValue = (facility.streetName || '').toLowerCase();
break;
case 'county':
searchValue = (facility.county || '').toLowerCase();
break;
case 'town':
searchValue = (facility.town || '').toLowerCase();
break;
case 'postcode':
searchValue = (facility.postcode || '').toLowerCase();
break;
case 'contributor':
searchValue = (facility.contributor || '').toLowerCase();
break;
}
return searchValue.includes(searchTerm);
}
return true;
});
// Update markers and list
updateMapMarkers(filteredFacilities);
updateFacilityList(filteredFacilities);
};
searchInput.addEventListener('input', handleSearch);
filterCat.addEventListener('change', handleSearch);
}
}
/**
* Update map markers without changing the circle or center
* @param {Array} facilities - Facilities to show on map
*/
function updateMapMarkers(facilities) {
// Clear existing markers
markers.forEach(marker => marker.remove());
markers = [];
// Add new markers
facilities.forEach(facility => {
const marker = L.marker([facility.lat, facility.lng])
.bindPopup(createPopupContent(facility))
.addTo(map);
markers.push(marker);
});
}
/**
* Show facility details in a popover
* @param {string} facilityId - The facility ID
*/
function showFacilityDetails(facilityId) {
const facility = facilities.find(f => f.id === parseInt(facilityId));
if (!facility) return;
// Create popover content
const content = `
<div class="facility-details p-2">
<h6 class="mb-2">${escapeHtml(facility.title)}</h6>
<p class="mb-2">
<span class="badge bg-${getCategoryColorClass(facility.category)} bg-opacity-10 text-${getCategoryColorClass(facility.category)}">
${escapeHtml(facility.category)}
</span>
</p>
<p class="mb-2 small">${escapeHtml(facility.description)}</p>
<p class="mb-2 small">
<strong>Address:</strong><br>
${escapeHtml(formatAddress(facility))}
</p>
<p class="mb-0 small">
<strong>Added by:</strong> ${escapeHtml(facility.contributor)}
</p>
</div>
`;
// Find the marker for this facility
const marker = markers.find(m =>
m.getLatLng().lat === parseFloat(facility.lat) &&
m.getLatLng().lng === parseFloat(facility.lng)
);
if (marker) {
marker.bindPopup(content, {
maxWidth: 300,
className: 'facility-popup'
}).openPopup();
}
}
/**
* Format facility address
* @param {Object} facility - The facility data
* @returns {string} Formatted address
*/
function formatAddress(facility) {
const parts = [
facility.houseNumber,
facility.streetName,
facility.town,
facility.county,
facility.postcode
].filter(Boolean);
return parts.join(', ');
}
/**
* Get color class for facility category
* @param {string} category - The facility category
* @returns {string} Bootstrap color class
*/
function getCategoryColorClass(category) {
const categoryLower = (category || '').toLowerCase();
if (categoryLower.includes('recycling')) return 'success';
if (categoryLower.includes('green') || categoryLower.includes('roof')) return 'success';
if (categoryLower.includes('solar') || categoryLower.includes('power')) return 'warning';
if (categoryLower.includes('water') || categoryLower.includes('rain')) return 'info';
if (categoryLower.includes('battery')) return 'danger';
if (categoryLower.includes('bench')) return 'primary';
return 'secondary';
}
/**
* Escape HTML to prevent XSS
* @param {string} unsafe - Unsafe string
* @returns {string} Escaped string
*/
function 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;");
}

View File

@@ -1,152 +0,0 @@
/**
* Simplified Authentication Helper
*
* This provides a streamlined authentication solution that works with
* the simplified server-side authentication approach.
*/
class SimpleAuth {
/**
* Initialize the authentication helper
*/
constructor() {
this.token = localStorage.getItem('token');
this.user = JSON.parse(localStorage.getItem('user') || 'null');
}
/**
* 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;
}
}
/**
* Login a user
* @param {object} credentials - The user credentials (username, password)
* @returns {Promise<object>} The login result
*/
async login(credentials) {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
// Store token and user data
this.token = data.token;
localStorage.setItem('token', data.token);
// Parse user data from token
const payload = this.parseJwt(data.token);
this.user = {
id: payload.uid,
username: payload.username,
accessLevel: payload.accessLevel
};
localStorage.setItem('user', JSON.stringify(this.user));
return {
success: true,
user: this.user
};
} catch (error) {
console.error('Login error:', error);
return {
success: false,
error: error.message
};
}
}
/**
* Logout the current user
*/
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
// Redirect to home page
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;
}
/**
* 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
});
}
}
// Create a global instance
const auth = new SimpleAuth();