Update 34 files
- /nfc/README.md - /nfc/clean_old_logs.sh - /nfc/clockin.py - /nfc/emptycards.py - /nfc/sync_clocks.sh - /piservices/Dockerfile - /piservices/Portainer-lamp-stack.env - /piservices/Portainer-lamp-stack.yaml - /piservices/README.md - /piservices/avahi-alias.service - /piservices/clockpy.service - /piservices/emptycards.service - /piservices/nfc-sync.service - /piservices/test.sh - /www/404 - /www/get-table-data.php - /www/habiwan copy.png - /www/habiwan.png - /www/index.php - /www/log_swipe.php - /www/manage_data.php - /www/manifest.json - /www/names.csv - /www/network_audit.csv - /www/network-tracker.html - /www/nfc_debug.log - /www/scanner.html - /www/staff_names.php - /www/times.csv - /www/check_update.php - /www/dev.php - /www/favicon.png - /www/JsBarcode.all.min.js - /www/README.md
This commit is contained in:
1 parent
10824ab851
commit
a201d5d7ac
34 files changed
+9453
No files matched your search
@@ -0,0 +1,45 @@
|
|||||||
|
Use this folder as an example of what should be in your working "NFC" python folder on your pi.
|
||||||
|
This was the folder that was generated after all the compliling of the NFC manufacturer software.
|
||||||
|
(broadly explained in the main README.md...)
|
||||||
|
This folder, in my example also has a few shell scripts that I run via services...
|
||||||
|
in my example I mounted a "NAS" and a "www" folder on the pi's /etc/fstab so the scripts therein can copy
|
||||||
|
names.csv and times.csv on those locations with simple cp commands...
|
||||||
|
You could say that IF you want to implement your own version and want to do it my way, this a PREREQUISITE then:
|
||||||
|
|
||||||
|
mount on the pi's /etc/fstab 2 targets:
|
||||||
|
- //192.168.X.X/NASFOLDER /media/NAS cifs credentials=/root/.NASCREDS,uid=1000,gid=1000,noauto,x-systemd.automount,x-systemd.mount-timeout=30 0 0
|
||||||
|
- YOURUVMUSER@192.168.X.X:/home/YOURUVMUSER/docker/docker-compose-lamp/www /media/uvm fuse.sshfs noauto,x-systemd.automount,_netdev,allow_other,IdentityFile=/root/.ssh/id_rsa,StrictHostKeyChecking=accept-new,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 0 0
|
||||||
|
|
||||||
|
For the SMB CIFS I added the NASCREDS file with username and password in the RPi /root/ folder (root runs these scripts in services! that's why... room for improvement though)
|
||||||
|
|
||||||
|
For the SSHFS UVM www folder mount on the pi, first apt install sshfs then you got to exchange keys and have them in ../html/www/.ssh (I know is not optimal, I am working on hardening see TODO.md)
|
||||||
|
|
||||||
|
I also wanted to keep my samba mount for local dev on the php code an the UMV machine but not as guest so I had to run smbpasswd command on the UVM (UbuntuVM).
|
||||||
|
|
||||||
|
If you also want to keep the /etc/samba/smb.conf on the UVM you still want to keep samba with password secured:
|
||||||
|
|
||||||
|
[www]
|
||||||
|
|
||||||
|
path = /home/YOURUVMUSER/docker/docker-compose-lamp/www
|
||||||
|
browseable = yes
|
||||||
|
writable = yes
|
||||||
|
read only = no
|
||||||
|
public = no
|
||||||
|
guest ok = no
|
||||||
|
valid users = YOURUVMUSER
|
||||||
|
force user = YOURUVMUSER
|
||||||
|
create mask = 0664
|
||||||
|
directory mask = 0775
|
||||||
|
|
||||||
|
- mount it manually once on the pi, it will ask for password then it should be using the ssh keys... this should definitely be improved for production.
|
||||||
|
|
||||||
|
The scripts on the pi run as a service by root, so they can copy names.csv and times.csv to the "NAS" and "UVM"
|
||||||
|
|
||||||
|
In my example I used a "NAS" wannabe... horrible consumer grade WD MyCloud for backups and where the Excel got it's data from
|
||||||
|
and "UVM" which was a decomissioned now "upclycled" consumer-grade PC that I installed pve on...
|
||||||
|
|
||||||
|
If you want to use this like me, the rationale was to have the pi as lean as possible only processing the nfc card "scans" and copying the data over to
|
||||||
|
the "webapp - UbuntuVM - PVE machine" and to the "NAS" for backups / disaster recovery... The processing goes to Excel clients or php / LAMP on the "PC"...
|
||||||
|
|
||||||
|
UPDATE:
|
||||||
|
I modified the main python so that it uses mysql so most of the above is not needed anymore byt feel free to keep the csvs as raw hardcopies... I modified the php pages to use mysql too and the Docker compose to a Portainer stack that could be used on a Pi too... so the csv file syncin no more needed... anymore
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# added sudo crontab to the pi to trim old data at 2 am:
|
||||||
|
# 0 2 * * * /bin/bash /home/YOURPIUSER/nfc/clean_old_logs.sh >/dev/null 2>&1
|
||||||
|
|
||||||
|
TIMES_FILE="/home/YOURPIUSER/nfc/times.csv"
|
||||||
|
|
||||||
|
# 1. Get the date 6 months ago (for testing)
|
||||||
|
LOG_CUTOFF=$(date -d "6 months ago" +%Y-%m-%d)
|
||||||
|
|
||||||
|
# 2. Filter using universally compatible literal matching
|
||||||
|
awk -v cutoff="$LOG_CUTOFF" '
|
||||||
|
{
|
||||||
|
if (match($0, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) {
|
||||||
|
date_part = substr($0, RSTART, RLENGTH)
|
||||||
|
if (date_part < cutoff) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print $0
|
||||||
|
}' "$TIMES_FILE" > "${TIMES_FILE}.tmp"
|
||||||
|
|
||||||
|
# 3. Safely replace the file
|
||||||
|
mv "${TIMES_FILE}.tmp" "$TIMES_FILE"
|
||||||
|
|
||||||
|
# 4. Force the sync script to grab the new file inode
|
||||||
|
systemctl restart nfc-sync.service
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
This example shows connecting to the PN532 with I2C (requires clock
|
||||||
|
stretching support), SPI, or UART. SPI is best, it uses the most pins but
|
||||||
|
is the most reliable and universally supported.
|
||||||
|
After initialization, try waving various 13.56MHz RFID cards over it!
|
||||||
|
Modified and enhanced by F.Javier "habiwan" Puig Diaz in May/June 2025
|
||||||
|
Remember to adjust the paths!
|
||||||
|
It writes the raw times.csv file which is then used with names.csv later during processing.
|
||||||
|
This example is production ready and has been working since June 2025.
|
||||||
|
We were using an excel file that lists the date swiped and name of employee,
|
||||||
|
but now I developed a php webapp with the same functionality and nicer look.
|
||||||
|
Copyleft by F.Javier Puig Diaz, July 2026
|
||||||
|
UPDATE: mysql for lamp usage (pip3 install mysql-connector-python first of course...)
|
||||||
|
"""
|
||||||
|
import RPi.GPIO as GPIO
|
||||||
|
import vcgencmd
|
||||||
|
import mysql.connector
|
||||||
|
from datetime import datetime
|
||||||
|
import time # Imported time module directly for our debounce math
|
||||||
|
from pn532 import *
|
||||||
|
|
||||||
|
# --- DATABASE CONFIGURATION ---
|
||||||
|
db_config = {
|
||||||
|
'host': 'db', # Or your Pi's IP/localhost
|
||||||
|
'user': 'root',
|
||||||
|
'password': 'rootpassword',
|
||||||
|
'database': 'lampapp'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- DEBOUNCE CONFIGURATION ---
|
||||||
|
last_swipe_times = {}
|
||||||
|
DEBOUNCE_SECONDS = 60
|
||||||
|
|
||||||
|
def get_cpu_temp():
|
||||||
|
"""Fetches the CPU temperature using vcgencmd."""
|
||||||
|
try:
|
||||||
|
# Standard approach for vcgencmd on Raspberry Pi
|
||||||
|
temp = vcgencmd.measure_temp()
|
||||||
|
# Returns a string like "temp=56.0'C", we need to extract the float
|
||||||
|
temp_value = float(temp.replace("temp=", "").replace("'C", ""))
|
||||||
|
return temp_value
|
||||||
|
except:
|
||||||
|
return 0.0 # Fallback if reading fails
|
||||||
|
|
||||||
|
def ensure_tables_exist(cursor):
|
||||||
|
"""Creates tables if they are missing."""
|
||||||
|
# Create 'names' table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS names (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
CardID VARCHAR(12) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) DEFAULT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Create 'times' table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS times (
|
||||||
|
UID VARCHAR(12) NOT NULL,
|
||||||
|
temp FLOAT,
|
||||||
|
timestamp DATETIME
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def log_to_db(uid_str, timestamp):
|
||||||
|
try:
|
||||||
|
conn = mysql.connector.connect(**db_config)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# --- NEW: Ensure structure exists before inserting ---
|
||||||
|
ensure_tables_exist(cursor)
|
||||||
|
|
||||||
|
cpu_temp = get_cpu_temp()
|
||||||
|
|
||||||
|
sql = "INSERT INTO times (UID, temp, timestamp) VALUES (%s, %s, %s)"
|
||||||
|
cursor.execute(sql, (uid_str, cpu_temp, timestamp))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
except mysql.connector.Error as err:
|
||||||
|
print(f"Database error: {err}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
pn532 = PN532_I2C(debug=False, reset=20, req=16)
|
||||||
|
ic, ver, rev, support = pn532.get_firmware_version()
|
||||||
|
print('Found PN532 with firmware version: {0}.{1}'.format(ver, rev))
|
||||||
|
pn532.SAM_configuration()
|
||||||
|
|
||||||
|
GPIO.setwarnings(False)
|
||||||
|
GPIO.setmode(GPIO.BCM)
|
||||||
|
buzzer = 23
|
||||||
|
GPIO.setup(buzzer, GPIO.OUT)
|
||||||
|
|
||||||
|
# Boot beep
|
||||||
|
GPIO.output(buzzer, GPIO.HIGH)
|
||||||
|
time.sleep(0.1)
|
||||||
|
GPIO.output(buzzer, GPIO.LOW)
|
||||||
|
|
||||||
|
print('Waiting for RFID/NFC card...')
|
||||||
|
|
||||||
|
while True:
|
||||||
|
uid = pn532.read_passive_target(timeout=0.5)
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert list of hex to a clean string immediately
|
||||||
|
uid_str = " ".join([hex(i) for i in uid])
|
||||||
|
current_epoch = time.time()
|
||||||
|
|
||||||
|
# --- Per-Card Debounce Logic ---
|
||||||
|
if uid_str in last_swipe_times:
|
||||||
|
time_since_last = current_epoch - last_swipe_times[uid_str]
|
||||||
|
if time_since_last < DEBOUNCE_SECONDS:
|
||||||
|
# Ignore the card if it was swiped less than 60 seconds ago
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Record the new swipe time for this specific card
|
||||||
|
last_swipe_times[uid_str] = current_epoch
|
||||||
|
# -------------------------------
|
||||||
|
|
||||||
|
# Log to DB
|
||||||
|
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
log_to_db(uid_str, current_time)
|
||||||
|
|
||||||
|
# Buzzer feedback
|
||||||
|
GPIO.output(buzzer, GPIO.HIGH)
|
||||||
|
time.sleep(0.1)
|
||||||
|
GPIO.output(buzzer, GPIO.LOW)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"System error: {e}")
|
||||||
|
finally:
|
||||||
|
GPIO.cleanup()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
This example shows connecting to the PN532 with I2C (requires clock
|
||||||
|
stretching support), SPI, or UART. SPI is best, it uses the most pins but
|
||||||
|
is the most reliable and universally supported.
|
||||||
|
After initialization, try waving various 13.56MHz RFID cards over it!
|
||||||
|
Modified and enhanced by F.Javier "habiwan" Puig Diaz in May/June 2025
|
||||||
|
This optional set of script/service is to get an excel file file written with the codes of empty cards you swipe (whevever you get new cards)
|
||||||
|
For example you could get an excel file written with the codes and matching names so a number can be written on empty cards
|
||||||
|
uses the production names.csv that I got from prescanning 100 cards and getting their unique ID on column 1 and _UNASSIGNEDXX on column 2
|
||||||
|
so that matching names to their unique ID is simple. I suggest writing with a pen a number to a label on those new empty cards...
|
||||||
|
This uses the same production names.csv file, new names like _UNASSIGNEDXX should be column 2 column 1 the unique ID stripped as shown in the get-table-data.php example:
|
||||||
|
Look for /** 2. PARSE AND MAP TIMESTAMPS (times.csv) **/ and see what $combinedData = []; does...
|
||||||
|
... or use an EXCEL with the formulae shown in the main README.md ....
|
||||||
|
GOAL is to see the results after swiping a card and writing a little number in the corner with the unassigned card number for future employees / temps...
|
||||||
|
This way the empty cards that all look the same will have the labelled number matched to their unique ID for later processing...
|
||||||
|
Remember to adjust the paths!
|
||||||
|
"""
|
||||||
|
|
||||||
|
import RPi.GPIO as GPIO # used for nfc and buzzer
|
||||||
|
import vcgencmd # added for CPU temperature readings
|
||||||
|
from datetime import datetime # added for timestamps
|
||||||
|
from time import sleep # added for buzzer
|
||||||
|
from pathlib import Path # added to 'touch' the csv if it does not exist yet or gets deleted
|
||||||
|
|
||||||
|
from pn532 import *
|
||||||
|
Path('/home/YOURPIUSER/nfc/emptycards.csv').touch()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
pn532 = PN532_I2C(debug=False, reset=20, req=16)
|
||||||
|
|
||||||
|
ic, ver, rev, support = pn532.get_firmware_version()
|
||||||
|
print('Found PN532 with firmware version: {0}.{1}'.format(ver, rev))
|
||||||
|
|
||||||
|
# Configure PN532 to communicate with MiFare cards
|
||||||
|
pn532.SAM_configuration()
|
||||||
|
|
||||||
|
#Disable warnings (optional)
|
||||||
|
GPIO.setwarnings(False)
|
||||||
|
#Select GPIO mode
|
||||||
|
GPIO.setmode(GPIO.BCM)
|
||||||
|
#Set buzzer - pin 23 as output
|
||||||
|
buzzer=23
|
||||||
|
GPIO.setup(buzzer,GPIO.OUT)
|
||||||
|
# debug Beep on boot
|
||||||
|
GPIO.output(buzzer,GPIO.HIGH)
|
||||||
|
sleep(0.01)
|
||||||
|
GPIO.output(buzzer,GPIO.LOW)
|
||||||
|
print('Waiting for RFID/NFC card...')
|
||||||
|
while True:
|
||||||
|
# Check if a card is available to read
|
||||||
|
uid = pn532.read_passive_target(timeout=0.5)
|
||||||
|
print('', end="")
|
||||||
|
# Try again if no card is available.
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
temp = vcgencmd.measure_temp()
|
||||||
|
s = ["UID: ", [hex(i) for i in uid], temp, str(datetime.now())]
|
||||||
|
with open("/home/YOURPIUSER/nfc/emptycards.csv", "a") as f:
|
||||||
|
f.writelines(str(s))
|
||||||
|
f.write("\n")
|
||||||
|
print(s) # debug
|
||||||
|
GPIO.output(buzzer,GPIO.HIGH)
|
||||||
|
#print("Beep") # debug
|
||||||
|
sleep(0.1) # Beep delay in seconds
|
||||||
|
GPIO.output(buzzer,GPIO.LOW)
|
||||||
|
sleep(1.7) # debounce
|
||||||
|
f.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
finally:
|
||||||
|
GPIO.cleanup()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Paths to the files we are watching
|
||||||
|
TIMES_FILE="/home/YOURPIUSER/nfc/times.csv"
|
||||||
|
NAMES_FILE="/home/YOURPIUSER/nfc/names.csv"
|
||||||
|
|
||||||
|
echo "Watching $TIMES_FILE and $NAMES_FILE for changes..."
|
||||||
|
|
||||||
|
# Loop indefinitely
|
||||||
|
while inotifywait -e modify "$TIMES_FILE" "$NAMES_FILE"; do
|
||||||
|
echo "Change detected! Copying files..."
|
||||||
|
|
||||||
|
# copy commands that happen after any swipe (or NFC-card name change):
|
||||||
|
cp /home/YOURPIUSER/nfc/*.csv /media/NAS/PiClock/nfc/ # make sure you mount these 2 on your pi's /etc/fstab
|
||||||
|
cp /home/YOURPIUSER/nfc/*.csv /media/uvm/ # see nfc/pifolder.md for more info
|
||||||
|
|
||||||
|
echo "Sync complete."
|
||||||
|
done
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM php:8.2-apache
|
||||||
|
|
||||||
|
# Install the missing MySQL driver
|
||||||
|
RUN docker-php-ext-install pdo pdo_mysql
|
||||||
|
|
||||||
|
# Align www-data with your host's user ID to eliminate the permission ping-pong
|
||||||
|
RUN usermod -u 1000 www-data && groupmod -g 1000 www-data
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
LAMPAPP=lampapp
|
||||||
|
LAMPUSER=lampuser
|
||||||
|
LAMPPASSWORD=lamppassword
|
||||||
|
MYSQL_ROOT_HOST=%
|
||||||
|
MYSQL_ROOT_PASSWORD=rootpassword
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: custom-lamp-php
|
||||||
|
container_name: lamp-web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 8080:80
|
||||||
|
volumes:
|
||||||
|
- /home/YOUR_USER/Code/lamp/www:/var/www/html
|
||||||
|
- /home/YOUR_USER/Code/lamp/apache/000-default.conf:/etc/apache2/sites-available/000-default.conf
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: db
|
||||||
|
MYSQL_DATABASE: ${LAMPAPP}
|
||||||
|
MYSQL_USER: ${LAMPUSER}
|
||||||
|
MYSQL_PASSWORD: ${LAMPPASSWORD}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- lamp-net
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: lamp-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
|
MYSQL_ROOT_HOST: ${MYSQL_ROOT_HOST}
|
||||||
|
MYSQL_DATABASE: ${LAMPAPP}
|
||||||
|
MYSQL_USER: ${LAMPUSER}
|
||||||
|
MYSQL_PASSWORD: ${LAMPPASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
- mysql_init:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- lamp-net
|
||||||
|
adminer:
|
||||||
|
image: adminer:latest
|
||||||
|
container_name: lamp-adminer
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 8081:8080
|
||||||
|
environment:
|
||||||
|
ADMINER_DEFAULT_SERVER: db
|
||||||
|
networks:
|
||||||
|
- lamp-net
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
mysql_init:
|
||||||
|
networks:
|
||||||
|
lamp-net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Use this folder as an example of what services you should install in your pi:
|
||||||
|
|
||||||
|
- Docker (install Portainer too)
|
||||||
|
- avahi-alias (optional - create a hostname alias for LAN testing)
|
||||||
|
- clockpy.service (this runs the nfc script in memory ready to be read)
|
||||||
|
- emptycards.service (used initially to write a csv file with all NFC codes in it)
|
||||||
|
- nfc-sync.service (when NFS card swiped on pi-NFC-reader it copies csv files across NAS and www machine - obsolete)
|
||||||
|
|
||||||
|
During prototyping I ran the services on this folder on the pi so they run in term the shell scripts
|
||||||
|
|
||||||
|
as you can see from the nfc folder... subject to your own opinion / implementation...
|
||||||
|
|
||||||
|
UPDATE: the latest version can be run on the same pi via docker Portainer lamp stack:
|
||||||
|
|
||||||
|
Optional services you could run on your machine that runs the webapp.
|
||||||
|
|
||||||
|
For me, this is was decomissioned "upcycled" consumer grade PC that I installed PVE on for prototyping. But now with the latest version you can simply run www and mysql on the arm64 portainer docker compose and env...
|
||||||
|
|
||||||
|
This was initially on an Ubuntu VM for me that runs docker-compose-lamp with phpmyadmin. Since phpmyadmin could not run on my pi I decided to use adminer instead.
|
||||||
|
|
||||||
|
This gives me the quick and easy way to run php, but it was "overkill" as I was planning to use mysql, and now that I do I migrated to docker on pi.
|
||||||
|
|
||||||
|
and now! everybody! no more clucky ugly unsatisfactory silly csv files... running on MySQL now!!!
|
||||||
|
|
||||||
|
The additional service that I ended up wanting on my setup was to use the avahi capabilities to give the www enpoint
|
||||||
|
|
||||||
|
a "nickname" like e.g. http://piclock.local or just simple http://piclock works for me too...
|
||||||
|
|
||||||
|
At the same time it keeps your original "uvm" or "rpi" hostname endpoint alive, up to you if you want this or not...
|
||||||
|
|
||||||
|
UPDATE: modified so it runs on a Pi itself too... if you can run docker on the pi you can install Portainer and use this new lamp stack that uses adminer instead of phpmyadmin... that was the big stopper I unlocked.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Avahi mDNS Alias for piclock.local
|
||||||
|
After=avahi-daemon.service
|
||||||
|
Requires=avahi-daemon.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# This service is the only service that (if needed/wanted) has to run on the UbuntuVM or PC that has the php code...
|
||||||
|
# Adjust the IP !
|
||||||
|
ExecStart=/usr/bin/avahi-publish -a -R piclock.local 192.168.X.X
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PiClock System raw data writing service
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Remember to adjust the paths!
|
||||||
|
ExecStart=/usr/bin/python /home/YOURPIUSER/nfc/clockin.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PiClock Empty card assist tool
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Remember to adjust the path!
|
||||||
|
ExecStart=/usr/bin/python /home/YOURPIUSER/nfc/emptycards.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Sync NFC logs on times.csv modification
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Remember to adjust the path and user!
|
||||||
|
User=YOURPIUSER
|
||||||
|
ExecStart=/home/YOURPIUSER/nfc/sync_clocks.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="lamp-db"
|
||||||
|
DB_NAME="lampapp"
|
||||||
|
DB_USER="root"
|
||||||
|
DB_PASS="rootpassword"
|
||||||
|
|
||||||
|
# --- Debounce Logic ---
|
||||||
|
DEBOUNCE_FILE="/tmp/last_swipe_trigger"
|
||||||
|
DEBOUNCE_SECONDS=60
|
||||||
|
CURRENT_EPOCH=$(date +%s)
|
||||||
|
|
||||||
|
# Check if the debounce file exists
|
||||||
|
if [[ -f "$DEBOUNCE_FILE" ]]; then
|
||||||
|
LAST_EPOCH=$(cat "$DEBOUNCE_FILE")
|
||||||
|
TIME_DIFF=$((CURRENT_EPOCH - LAST_EPOCH))
|
||||||
|
|
||||||
|
# If less than 60 seconds have passed, exit silently without logging
|
||||||
|
if [[ "$TIME_DIFF" -lt "$DEBOUNCE_SECONDS" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update the debounce file with the new trigger time
|
||||||
|
echo "$CURRENT_EPOCH" > "$DEBOUNCE_FILE"
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
# Get the current time from the Pi host (Europe/London)
|
||||||
|
CURRENT_TIME=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# SQL Logic
|
||||||
|
QUERY="
|
||||||
|
CREATE TABLE IF NOT EXISTS names (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
CardID VARCHAR(12) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS times (
|
||||||
|
UID VARCHAR(12) NOT NULL,
|
||||||
|
temp FLOAT,
|
||||||
|
timestamp DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Insert the test into names ONLY if it doesn't already exist */
|
||||||
|
INSERT INTO names (CardID, name)
|
||||||
|
SELECT 'test001', 'zz_manual_test' FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM names WHERE CardID = 'test001'
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Always log the time entry */
|
||||||
|
INSERT INTO times (UID, temp, timestamp) VALUES ('test001', 56.0, '$CURRENT_TIME');
|
||||||
|
"
|
||||||
|
|
||||||
|
# Execute inside the container
|
||||||
|
docker exec "$CONTAINER_NAME" mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "$QUERY"
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
|
||||||
|
<meta name="viewport" content="
|
||||||
|
width=device-width,
|
||||||
|
initial-scale=1.0,
|
||||||
|
maximum-scale=1.0,
|
||||||
|
user-scalable=no,
|
||||||
|
viewport-fit=cover">
|
||||||
|
|
||||||
|
<link rel="shortcut icon"
|
||||||
|
href="
|
||||||
|
https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/UZ_road_sign_1.25.2.svg/960px-UZ_road_sign_1.25.2.svg.png"
|
||||||
|
type="image/svg+xml">
|
||||||
|
|
||||||
|
<title>404 not found</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ==========================================
|
||||||
|
BASE LAYOUT & TYPOGRAPHY
|
||||||
|
========================================== */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100dvh;
|
||||||
|
|
||||||
|
/* Background gradient outside the game container */
|
||||||
|
background: linear-gradient(to bottom, #64b3f4, #c2e59c);
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Prevents accidental text selection or zooming
|
||||||
|
while frantically tapping the screen */
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
GAME CONTAINER & CANVAS
|
||||||
|
========================================== */
|
||||||
|
.game-container {
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Maintains a standard portrait aspect ratio */
|
||||||
|
aspect-ratio: 360 / 640;
|
||||||
|
width: 90vw;
|
||||||
|
height: 90dvh;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
border-radius: 16px;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjusts sizing for desktop monitors */
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.game-container {
|
||||||
|
height: 95dvh;
|
||||||
|
max-height: 95%;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #70c5ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
USER INTERFACE (UI) OVERLAY
|
||||||
|
========================================== */
|
||||||
|
/* Acts as a transparent layer sitting on top of the
|
||||||
|
HTML5 canvas to hold our HTML text and buttons */
|
||||||
|
.ui-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
/* Allows clicks to pass through the overlay
|
||||||
|
and hit the canvas behind it */
|
||||||
|
pointer-events: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4cqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Groups the different UI screens */
|
||||||
|
#startScreen, #gameOverScreen, #gameUI {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
font-size: 13cqw;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 4cqw 0;
|
||||||
|
text-shadow: 0 0.8cqw 2cqw rgba(0,0,0,0.6);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-display {
|
||||||
|
font-size: 16cqw;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0.6cqw 2cqw rgba(0, 0, 0, 0.5);
|
||||||
|
margin-top: 2cqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
padding: 4cqw 6cqw;
|
||||||
|
border-radius: 3cqw;
|
||||||
|
font-size: 6cqw;
|
||||||
|
width: 85%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 4cqw;
|
||||||
|
box-shadow: 0 1cqw 3cqw rgba(0, 0, 0, 0.3);
|
||||||
|
border: 2px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 3.5cqw 5cqw;
|
||||||
|
border-radius: 2cqw;
|
||||||
|
font-size: 4.5cqw;
|
||||||
|
margin-top: 3cqw;
|
||||||
|
width: 85%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #f1c40f;
|
||||||
|
border: none;
|
||||||
|
padding: 4cqw 10cqw;
|
||||||
|
font-size: 6.5cqw;
|
||||||
|
border-radius: 10cqw;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #2c3e50;
|
||||||
|
box-shadow: 0 1.5cqw 4cqw rgba(0, 0, 0, 0.4);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
|
||||||
|
/* Re-enables clicking specifically for buttons */
|
||||||
|
pointer-events: auto;
|
||||||
|
margin-top: 2cqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adds a slight physical "press" effect when clicked */
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
LEADERBOARD & HIGHSCORE CSS
|
||||||
|
========================================== */
|
||||||
|
input[type="text"] {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 2px solid #2c3e50;
|
||||||
|
padding: 2.5cqw 4cqw;
|
||||||
|
border-radius: 2cqw;
|
||||||
|
font-size: 5cqw;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
text-align: center;
|
||||||
|
width: 60%;
|
||||||
|
margin-bottom: 3cqw;
|
||||||
|
text-transform: uppercase;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
/* Re-enables clicking and typing for the input box */
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: #f1c40f;
|
||||||
|
box-shadow: 0 0 10px rgba(241, 196, 15, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5cqw;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||||
|
padding-bottom: 1cqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility class to easily hide UI elements */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="game-container">
|
||||||
|
<canvas id="gameCanvas" width="360" height="640"></canvas>
|
||||||
|
|
||||||
|
<div class="ui-overlay">
|
||||||
|
|
||||||
|
<div id="startScreen">
|
||||||
|
<h1 class="game-title">404 error<br>page not found</h1>
|
||||||
|
<div class="message">
|
||||||
|
Tap the screen or<br>
|
||||||
|
click the mouse or<br>
|
||||||
|
hit spacebar to<br>
|
||||||
|
"flap your wings"
|
||||||
|
</div>
|
||||||
|
<button id="startButton">START GAME</button>
|
||||||
|
<div class="instructions">
|
||||||
|
Avoid the pipes and<br>
|
||||||
|
don't hit the ground!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gameOverScreen" class="hidden">
|
||||||
|
<div class="message" style="font-size: 8cqw;
|
||||||
|
font-weight:bold;
|
||||||
|
color: #ff4757;
|
||||||
|
margin-bottom: 2cqw;">
|
||||||
|
GAME OVER
|
||||||
|
</div>
|
||||||
|
<div class="score-display" style="margin-top: 0;
|
||||||
|
margin-bottom: 2cqw;">
|
||||||
|
Score: <span id="finalScore">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="highscoreEntry" class="hidden" style="display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;">
|
||||||
|
<div style="font-size: 5cqw;
|
||||||
|
margin-bottom: 2cqw;
|
||||||
|
color: #f1c40f;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.5);">
|
||||||
|
🎉 NEW HIGH SCORE 🏆
|
||||||
|
</div>
|
||||||
|
<input type="text" id="playerName" placeholder="YOUR NAME" maxlength="13">
|
||||||
|
<button id="saveScoreButton" style="font-size: 5cqw;
|
||||||
|
padding: 2.5cqw 8cqw;">
|
||||||
|
SAVE SCORE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="leaderboard" class="instructions" style="margin-top: 2cqw;
|
||||||
|
font-size: 4.5cqw;">
|
||||||
|
<div style="font-weight: 900;
|
||||||
|
margin-bottom: 3cqw;
|
||||||
|
text-align: center;
|
||||||
|
color: #f1c40f;">
|
||||||
|
TOP 5 SCORES
|
||||||
|
</div>
|
||||||
|
<div id="leaderboardList" style="text-align: left;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="restartButton" style="margin-top: 3cqw;">
|
||||||
|
TRY AGAIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gameUI" class="hidden">
|
||||||
|
<div class="score-display" id="score">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ==========================================
|
||||||
|
// 1. DOM ELEMENT BINDINGS
|
||||||
|
// ==========================================
|
||||||
|
// Grabbing references to HTML elements so we can
|
||||||
|
// manipulate them via JavaScript later.
|
||||||
|
const canvas = document.getElementById('gameCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const startScreen = document.getElementById('startScreen');
|
||||||
|
const gameOverScreen = document.getElementById('gameOverScreen');
|
||||||
|
const gameUI = document.getElementById('gameUI');
|
||||||
|
const scoreDisplay = document.getElementById('score');
|
||||||
|
const finalScoreDisplay = document.getElementById('finalScore');
|
||||||
|
const startButton = document.getElementById('startButton');
|
||||||
|
const restartButton = document.getElementById('restartButton');
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. GLOBAL GAME STATE & TRACKERS
|
||||||
|
// ==========================================
|
||||||
|
// Tracks the current menu/game phase.
|
||||||
|
let gameState = 'start';
|
||||||
|
|
||||||
|
// Current session score.
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Array to hold {name, score} objects for the leaderboard.
|
||||||
|
let highscores = [];
|
||||||
|
|
||||||
|
|
||||||
|
// Time and Speed setup for smooth animations.
|
||||||
|
let lastTime = 0;
|
||||||
|
let pipeSpawnTimer = 0;
|
||||||
|
|
||||||
|
// NEW: Party popper 🎉
|
||||||
|
let confettiParticles = [];
|
||||||
|
|
||||||
|
// Target pixel distance between pipes. Ensures that when
|
||||||
|
// the game speeds up, the horizontal gap stays identical.
|
||||||
|
const PIPE_DISTANCE = 220;
|
||||||
|
|
||||||
|
// Starting speed in pixels per second.
|
||||||
|
const BASE_SPEED = 120;
|
||||||
|
|
||||||
|
// How much faster to go every 10 points.
|
||||||
|
const SPEED_INCREMENT = 15;
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. GAME ENTITIES (BIRD & PIPES)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const bird = {
|
||||||
|
// Position and dimensions
|
||||||
|
x: 50,
|
||||||
|
y: canvas.height / 2 - 10,
|
||||||
|
width: 34,
|
||||||
|
height: 24,
|
||||||
|
|
||||||
|
// Physics variables tuned by the user
|
||||||
|
gravity: 20,
|
||||||
|
velocity: 0,
|
||||||
|
jump: -500,
|
||||||
|
|
||||||
|
// Renders the bird using basic Canvas shapes
|
||||||
|
draw: function() {
|
||||||
|
// Body (Yellow Circle)
|
||||||
|
ctx.fillStyle = '#FFD700';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, 15, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eye (Black Dot)
|
||||||
|
ctx.fillStyle = 'black';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x + 8, this.y - 3, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Beak (Orange Triangle)
|
||||||
|
ctx.fillStyle = '#FF8C00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(this.x + 15, this.y);
|
||||||
|
ctx.lineTo(this.x + 30, this.y);
|
||||||
|
ctx.lineTo(this.x + 15, this.y + 8);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Wing (Darker Orange Ellipse)
|
||||||
|
ctx.fillStyle = '#FFA500';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(this.x - 5, this.y + 5, 10, 7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Applies physics to the bird's vertical position
|
||||||
|
update: function(dt) {
|
||||||
|
if (gameState === 'playing') {
|
||||||
|
// Apply gravity over time
|
||||||
|
this.velocity += this.gravity * dt * 60;
|
||||||
|
|
||||||
|
// Move the bird by the current velocity
|
||||||
|
this.y += this.velocity * dt;
|
||||||
|
|
||||||
|
// Floor collision check
|
||||||
|
if (this.y + this.height/2 >= canvas.height - groundHeight) {
|
||||||
|
this.y = canvas.height - groundHeight - this.height/2;
|
||||||
|
if (gameState === 'playing') gameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ceiling collision check
|
||||||
|
if (this.y - this.height/2 <= 0) {
|
||||||
|
this.y = this.height/2;
|
||||||
|
// Hitting the ceiling stops upward momentum
|
||||||
|
this.velocity = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Triggers a jump by applying negative velocity
|
||||||
|
flap: function() {
|
||||||
|
this.velocity = this.jump;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resets bird position for a new game
|
||||||
|
reset: function() {
|
||||||
|
this.y = canvas.height / 2 - 10;
|
||||||
|
this.velocity = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pipes = {
|
||||||
|
// Array to store active pipes currently on screen
|
||||||
|
position: [],
|
||||||
|
// Vertical gap between the top and bottom pipe
|
||||||
|
gap: 180,
|
||||||
|
// Defines how high the top pipe can randomly spawn
|
||||||
|
maxYPos: -150,
|
||||||
|
// Current horizontal speed (dx)
|
||||||
|
dx: BASE_SPEED,
|
||||||
|
|
||||||
|
// Renders all active pipes in the array
|
||||||
|
draw: function() {
|
||||||
|
for (let i = 0; i < this.position.length; i++) {
|
||||||
|
let p = this.position[i];
|
||||||
|
|
||||||
|
// Draw Top Pipe
|
||||||
|
ctx.fillStyle = '#2ECC71';
|
||||||
|
ctx.fillRect(p.x, p.y, p.width, p.height);
|
||||||
|
|
||||||
|
// Draw Top Pipe Cap
|
||||||
|
ctx.fillStyle = '#27AE60';
|
||||||
|
ctx.fillRect(p.x - 3, p.y + p.height - 20, p.width + 6, 20);
|
||||||
|
|
||||||
|
// Draw Bottom Pipe
|
||||||
|
ctx.fillStyle = '#2ECC71';
|
||||||
|
ctx.fillRect(p.x, p.y + p.height + this.gap, p.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw Bottom Pipe Cap
|
||||||
|
ctx.fillStyle = '#27AE60';
|
||||||
|
ctx.fillRect(p.x - 3, p.y + p.height + this.gap, p.width + 6, 20);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handles spawning, moving, scoring, and pipe collision
|
||||||
|
update: function(dt) {
|
||||||
|
if (gameState !== 'playing') return;
|
||||||
|
|
||||||
|
// Advance the timer by delta time
|
||||||
|
pipeSpawnTimer += dt;
|
||||||
|
|
||||||
|
// Calculate time required to travel the target distance
|
||||||
|
// at the current speed, keeping gaps consistent.
|
||||||
|
let currentSpawnInterval = PIPE_DISTANCE / this.dx;
|
||||||
|
|
||||||
|
// Spawn a new pipe if enough time has passed
|
||||||
|
if (pipeSpawnTimer >= currentSpawnInterval) {
|
||||||
|
this.position.push({
|
||||||
|
x: canvas.width,
|
||||||
|
y: this.maxYPos * (Math.random() + 1),
|
||||||
|
width: 60,
|
||||||
|
height: 300
|
||||||
|
});
|
||||||
|
// Reset timer after spawning
|
||||||
|
pipeSpawnTimer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update positions and check collisions
|
||||||
|
for (let i = 0; i < this.position.length; i++) {
|
||||||
|
let p = this.position[i];
|
||||||
|
|
||||||
|
// Move pipe to the left
|
||||||
|
p.x -= this.dx * dt;
|
||||||
|
|
||||||
|
// If pipe moves entirely off-screen to the left:
|
||||||
|
// Remove it, add a point, and check for speed up.
|
||||||
|
if (p.x + p.width <= 0) {
|
||||||
|
this.position.splice(i, 1);
|
||||||
|
score++;
|
||||||
|
scoreDisplay.innerHTML = score;
|
||||||
|
|
||||||
|
// Increase difficulty speed every 10 points
|
||||||
|
if (score > 0 && score % 10 === 0) {
|
||||||
|
this.dx += SPEED_INCREMENT;
|
||||||
|
}
|
||||||
|
// Move to the next pipe in the loop
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLLISION LOGIC
|
||||||
|
// Check collision with the TOP pipe
|
||||||
|
if (
|
||||||
|
bird.x + bird.width/2 > p.x &&
|
||||||
|
bird.x - bird.width/2 < p.x + p.width &&
|
||||||
|
bird.y - bird.height/2 < p.y + p.height
|
||||||
|
) {
|
||||||
|
gameOver();
|
||||||
|
}
|
||||||
|
// Check collision with the BOTTOM pipe
|
||||||
|
if (
|
||||||
|
bird.x + bird.width/2 > p.x &&
|
||||||
|
bird.x - bird.width/2 < p.x + p.width &&
|
||||||
|
bird.y + bird.height/2 > p.y + p.height + this.gap
|
||||||
|
) {
|
||||||
|
gameOver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clears pipes and resets speed for a new game
|
||||||
|
reset: function() {
|
||||||
|
this.position = [];
|
||||||
|
this.dx = BASE_SPEED;
|
||||||
|
pipeSpawnTimer = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. ENVIRONMENT RENDERING
|
||||||
|
// ==========================================
|
||||||
|
const groundHeight = 80;
|
||||||
|
|
||||||
|
function drawGround() {
|
||||||
|
// Main ground color
|
||||||
|
ctx.fillStyle = '#DAA520';
|
||||||
|
ctx.fillRect(0, canvas.height - groundHeight, canvas.width, groundHeight);
|
||||||
|
|
||||||
|
// Green grass line on top of the ground
|
||||||
|
ctx.fillStyle = '#2ECC71';
|
||||||
|
ctx.fillRect(0, canvas.height - groundHeight, canvas.width, 20);
|
||||||
|
|
||||||
|
// Small brown decorative dashes across the ground
|
||||||
|
ctx.fillStyle = '#8B4513';
|
||||||
|
for (let i = 0; i < canvas.width; i += 30) {
|
||||||
|
ctx.fillRect(i, canvas.height - groundHeight + 20, 15, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBackground() {
|
||||||
|
// Create a sky gradient from top to bottom
|
||||||
|
const skyGradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||||
|
skyGradient.addColorStop(0, '#70c5ce');
|
||||||
|
skyGradient.addColorStop(1, '#e0f7fa');
|
||||||
|
|
||||||
|
// Fill background with gradient
|
||||||
|
ctx.fillStyle = skyGradient;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw decorative clouds using overlapping arcs
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
|
|
||||||
|
// Cloud 1
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(100, 80, 30, 0, Math.PI * 2);
|
||||||
|
ctx.arc(130, 70, 35, 0, Math.PI * 2);
|
||||||
|
ctx.arc(160, 80, 25, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Cloud 2
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(300, 120, 30, 0, Math.PI * 2);
|
||||||
|
ctx.arc(330, 110, 35, 0, Math.PI * 2);
|
||||||
|
ctx.arc(360, 120, 25, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 5. HIGHSCORE & LEADERBOARD LOGIC
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Iterates through the highscores array and updates the DOM
|
||||||
|
function renderLeaderboard() {
|
||||||
|
const list = document.getElementById('leaderboardList');
|
||||||
|
|
||||||
|
// Display empty state if no scores exist
|
||||||
|
if (highscores.length === 0) {
|
||||||
|
list.innerHTML = "<div style='text-align:center;'>No scores yet!</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the array into HTML strings for display
|
||||||
|
list.innerHTML = highscores.map((entry, i) =>
|
||||||
|
`<div class="leaderboard-row">
|
||||||
|
<span>${i + 1}. ${entry.name}</span>
|
||||||
|
<span style="color: #f1c40f; font-weight: bold;">${entry.score}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles saving a new highscore when the user clicks submit
|
||||||
|
document.getElementById('saveScoreButton').addEventListener('click', () => {
|
||||||
|
const nameInput = document.getElementById('playerName');
|
||||||
|
|
||||||
|
// Get value, trim spaces, make uppercase.
|
||||||
|
// Fallback to 'ANONYMOUS' if left empty.
|
||||||
|
const name = nameInput.value.trim().toUpperCase() || 'ANONYMOUS';
|
||||||
|
|
||||||
|
// Push the new score object into the array
|
||||||
|
highscores.push({ name, score });
|
||||||
|
|
||||||
|
// Sort the array in descending order based on score
|
||||||
|
highscores.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Enforce a maximum of 5 entries in the leaderboard
|
||||||
|
if (highscores.length > 5) {
|
||||||
|
highscores.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the input form and show the try again button
|
||||||
|
document.getElementById('highscoreEntry').classList.add('hidden');
|
||||||
|
document.getElementById('restartButton').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Clear the input box for the next session
|
||||||
|
nameInput.value = '';
|
||||||
|
|
||||||
|
// Re-render the visual list
|
||||||
|
renderLeaderboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 6. GAME FLOW CONTROLS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Triggers the end sequence when the bird crashes
|
||||||
|
function gameOver() {
|
||||||
|
gameState = 'gameover';
|
||||||
|
finalScoreDisplay.innerHTML = score;
|
||||||
|
|
||||||
|
// Check if the current score qualifies for the top 5
|
||||||
|
const isHighScore = score > 0 &&
|
||||||
|
(highscores.length < 5 || score > highscores[highscores.length - 1].score);
|
||||||
|
|
||||||
|
if (isHighScore) {
|
||||||
|
spawnConfetti();
|
||||||
|
// Show input block, force user to save before restarting
|
||||||
|
document.getElementById('highscoreEntry').classList.remove('hidden');
|
||||||
|
document.getElementById('restartButton').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
// Skip input block, just show standard restart button
|
||||||
|
document.getElementById('highscoreEntry').classList.add('hidden');
|
||||||
|
document.getElementById('restartButton').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure leaderboard is updated visually
|
||||||
|
renderLeaderboard();
|
||||||
|
|
||||||
|
// Switch UI screens
|
||||||
|
gameOverScreen.classList.remove('hidden');
|
||||||
|
gameUI.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resets variables to start a fresh run
|
||||||
|
function resetGame() {
|
||||||
|
score = 0;
|
||||||
|
bird.reset();
|
||||||
|
pipes.reset();
|
||||||
|
scoreDisplay.innerHTML = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begins the active game loop
|
||||||
|
function startGame() {
|
||||||
|
gameState = 'playing';
|
||||||
|
|
||||||
|
// Hide menus, show in-game overlay
|
||||||
|
startScreen.classList.add('hidden');
|
||||||
|
gameOverScreen.classList.add('hidden');
|
||||||
|
gameUI.classList.remove('hidden');
|
||||||
|
|
||||||
|
resetGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create the confetti particles
|
||||||
|
function spawnConfetti() {
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
confettiParticles.push({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: -10, // Start just above the screen
|
||||||
|
vx: (Math.random() - 0.5) * 4, // Horizontal drift
|
||||||
|
vy: Math.random() * 3 + 2, // Speed falling
|
||||||
|
rotation: Math.random() * Math.PI * 2, // Starting angle
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 0.2, // How fast it spins
|
||||||
|
color: `hsl(${Math.random() * 360}, 100%, 50%)`,
|
||||||
|
life: 200 // Lasts a bit longer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update and draw confetti particles
|
||||||
|
function updateAndDrawConfetti() {
|
||||||
|
confettiParticles.forEach((p, i) => {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.rotation += p.rotationSpeed; // Update the angle
|
||||||
|
p.life--;
|
||||||
|
|
||||||
|
ctx.save(); // Save the current canvas state
|
||||||
|
|
||||||
|
// Move to the particle's position and rotate
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate(p.rotation);
|
||||||
|
|
||||||
|
// Draw the Rhomboid centered at 0,0
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
const w = 6; // Width
|
||||||
|
const h = 10; // Height
|
||||||
|
ctx.moveTo(0, -h / 2); // Top
|
||||||
|
ctx.lineTo(w / 2, 0); // Right
|
||||||
|
ctx.lineTo(0, h / 2); // Bottom
|
||||||
|
ctx.lineTo(-w / 2, 0); // Left
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore(); // Restore state so the rest of the game draws normally
|
||||||
|
|
||||||
|
if (p.life <= 0) confettiParticles.splice(i, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 7. CORE RENDER & UPDATE LOOP
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Master draw function that executes in visual order (back to front)
|
||||||
|
function draw() {
|
||||||
|
drawBackground();
|
||||||
|
pipes.draw();
|
||||||
|
drawGround();
|
||||||
|
bird.draw();
|
||||||
|
// Draw confetti on top of everything
|
||||||
|
updateAndDrawConfetti();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master update function that processes physics
|
||||||
|
function update(dt) {
|
||||||
|
bird.update(dt);
|
||||||
|
pipes.update(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The main game engine loop triggered by requestAnimationFrame
|
||||||
|
function loop(timestamp) {
|
||||||
|
// Calculate delta time (dt) in seconds.
|
||||||
|
// This ensures smooth movement regardless of framerate.
|
||||||
|
let dt = (timestamp - lastTime) / 1000;
|
||||||
|
lastTime = timestamp;
|
||||||
|
|
||||||
|
// Cap delta time to prevent physics glitches if the tab is backgrounded
|
||||||
|
if (dt > 0.1) dt = 0.1;
|
||||||
|
|
||||||
|
update(dt);
|
||||||
|
draw();
|
||||||
|
|
||||||
|
// Recursively call the loop for the next frame
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 8. INPUT LISTENERS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Wrapper function to process bird flapping
|
||||||
|
function flapBird() {
|
||||||
|
if (gameState === 'playing') {
|
||||||
|
bird.flap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touchscreen input processing
|
||||||
|
canvas.addEventListener('touchstart', function(e) {
|
||||||
|
// Only prevent default zooming behavior during active gameplay.
|
||||||
|
// This allows the highscore form typing to work normally.
|
||||||
|
if (gameState === 'playing') {
|
||||||
|
e.preventDefault();
|
||||||
|
flapBird();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Mouse click input processing
|
||||||
|
canvas.addEventListener('mousedown', flapBird);
|
||||||
|
|
||||||
|
// Keyboard spacebar input processing
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
// Prevent jumping if the user is currently typing their name
|
||||||
|
if (document.activeElement.id !== 'playerName') {
|
||||||
|
flapBird();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI button bindings
|
||||||
|
startButton.addEventListener('click', startGame);
|
||||||
|
restartButton.addEventListener('click', startGame);
|
||||||
|
|
||||||
|
// Prevents double-tap zooming on iOS devices during active play
|
||||||
|
let lastTouchEnd = 0;
|
||||||
|
document.addEventListener('touchend', function(e) {
|
||||||
|
if (gameState === 'playing') {
|
||||||
|
const now = (new Date()).getTime();
|
||||||
|
if (now - lastTouchEnd <= 300) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
lastTouchEnd = now;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Initializes the game loop upon loading the script
|
||||||
|
requestAnimationFrame((timestamp) => {
|
||||||
|
lastTime = timestamp;
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
+2462
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
|||||||
|
Use this folder as an example of what should be in your www root folder.
|
||||||
|
|
||||||
|
this folder in my example is a persistent volume of a docker compose LAMP setup.
|
||||||
|
|
||||||
|
I used a decomissioned but "upcycled" consumer grade PC to install PVE on it and this is a Ubuntu VM for me...
|
||||||
|
|
||||||
|
in my example I mount this folder on the pi's /etc/fstab so the scripts therein can copy
|
||||||
|
|
||||||
|
names.csv and times.csv here
|
||||||
|
|
||||||
|
(I also pi-mounted a "NAS" wannabe... horrible consumer grade WD MyCloud for backups)
|
||||||
|
|
||||||
|
EDIT: Added barcode for the staff names managment table in case unique barcodes wanted to be scanned
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
$dbHost = 'db';
|
||||||
|
$dbName = 'PiClock';
|
||||||
|
$dbUser = 'root';
|
||||||
|
$dbPass = 'rootpassword';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4", $dbUser, $dbPass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT UPDATE_TIME FROM information_schema.tables WHERE table_schema = ? AND table_name IN ('times', 'names')");
|
||||||
|
$stmt->execute([$dbName]);
|
||||||
|
$updateTimes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$namesCount = $pdo->query("SELECT COUNT(*) FROM names")->fetchColumn();
|
||||||
|
$timesCount = $pdo->query("SELECT COUNT(*) FROM times")->fetchColumn();
|
||||||
|
|
||||||
|
$fingerprint = implode('|', $updateTimes) . '|' . $namesCount . '|' . $timesCount;
|
||||||
|
echo md5($fingerprint);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "0";
|
||||||
|
}
|
||||||
|
?>
|
||||||
+355
@@ -0,0 +1,355 @@
|
|||||||
|
<?php
|
||||||
|
// Define defaults so they are never "undefined"
|
||||||
|
$rows = [];
|
||||||
|
$columns = [];
|
||||||
|
$total_rows = 0;
|
||||||
|
$total_pages = 0;
|
||||||
|
$page = 1;
|
||||||
|
$sort_col = '';
|
||||||
|
$sort_dir = 'ASC';
|
||||||
|
|
||||||
|
// Start a secure session
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// 1. CHOOSE YOUR DEV PASSWORD HERE:
|
||||||
|
define('DEV_PASSWORD', '123456');
|
||||||
|
define('DEV_TIMEOUT_SECONDS', 1800); // 30 min Inactivity threshold
|
||||||
|
|
||||||
|
// Handle Explicit Logout
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
unset($_SESSION['DEV_PASSWORD_authenticated']);
|
||||||
|
unset($_SESSION['DEV_last_activity']);
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Login Form Submission
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['x_secure_token'])) {
|
||||||
|
if ($_POST['x_secure_token'] === DEV_PASSWORD) {
|
||||||
|
$_SESSION['DEV_PASSWORD_authenticated'] = true;
|
||||||
|
$_SESSION['DEV_last_activity'] = time();
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$login_error = "Incorrect password access denied.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-Side Inactivity Check
|
||||||
|
if (isset($_SESSION['DEV_PASSWORD_authenticated']) && $_SESSION['DEV_PASSWORD_authenticated'] === true) {
|
||||||
|
if (isset($_SESSION['DEV_last_activity']) && (time() - $_SESSION['DEV_last_activity'] > DEV_TIMEOUT_SECONDS)) {
|
||||||
|
unset($_SESSION['DEV_PASSWORD_authenticated']);
|
||||||
|
unset($_SESSION['DEV_last_activity']);
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF'] . "?reason=timeout");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_SESSION['DEV_last_activity'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['reason']) && $_GET['reason'] === 'timeout') {
|
||||||
|
unset($_SESSION['DEV_PASSWORD_authenticated']);
|
||||||
|
unset($_SESSION['DEV_last_activity']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display Login Screen if not authenticated
|
||||||
|
if (!isset($_SESSION['DEV_PASSWORD_authenticated']) || $_SESSION['DEV_PASSWORD_authenticated'] !== true) {
|
||||||
|
$display_msg = "Developer Access";
|
||||||
|
if (isset($_GET['reason']) && $_GET['reason'] === 'timeout') {
|
||||||
|
$login_error = "Logged out due to 30 min of inactivity.";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Developer Login</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
body { background: linear-gradient(180deg, #1c3344, #102a33, #000000); min-height: 100vh; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.login-box { margin: auto; background: rgba(255, 255, 255, 0.95); padding: 40px 30px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); width: 100%; max-width: 380px; text-align: center; }
|
||||||
|
h2 { margin-bottom: 25px; color: #00796b; font-size: 1.8rem; font-weight: 600; }
|
||||||
|
input[type="password"] { width: 100%; padding: 15px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; }
|
||||||
|
button { width: 100%; padding: 15px; background-color: #00897b; color: white; border: none; border-radius: 8px; font-size: 1.1rem; cursor: pointer; transition: 0.3s; }
|
||||||
|
button:hover { background-color: #00695c; }
|
||||||
|
.error { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #ffcdd2; }
|
||||||
|
.footer { text-align: center; padding-bottom: 20px; color: rgba(255, 255, 255, 0.7); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="favicon.png" alt="PiClock">
|
||||||
|
<div class="login-box">
|
||||||
|
<h2><?= htmlspecialchars($display_msg) ?></h2>
|
||||||
|
<?php if (isset($login_error)): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($login_error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST" autocomplete="off">
|
||||||
|
<input type="password" name="x_secure_token" placeholder="Enter Dev Password" required autofocus>
|
||||||
|
<button type="submit">Secure Log In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
'PiClock' DB Manager <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2026
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Diaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Database Connection **/
|
||||||
|
$dbHost = 'db'; $dbName = 'PiClock'; $dbUser = 'root'; $dbPass = 'rootpassword';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4", $dbUser, $dbPass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
} catch (PDOException $e) { die("Database connection failed: " . $e->getMessage()); }
|
||||||
|
|
||||||
|
$message = "";
|
||||||
|
$current_table = (isset($_GET['table']) && $_GET['table'] === 'times') ? 'times' : 'names';
|
||||||
|
// Identify PK: 'UID' for times, 'id' for everything else
|
||||||
|
$pk = ($current_table === 'times') ? 'UID' : 'id';
|
||||||
|
|
||||||
|
// --- HANDLE POST ACTIONS ---
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
try {
|
||||||
|
if (isset($_POST['backup_all'])) {
|
||||||
|
$pdo->exec("DROP TABLE IF EXISTS names_backup; CREATE TABLE names_backup AS SELECT * FROM names;");
|
||||||
|
$pdo->exec("DROP TABLE IF EXISTS times_backup; CREATE TABLE times_backup AS SELECT * FROM times;");
|
||||||
|
$message = "<div class='msg-success'>Full Database Backup created!</div>";
|
||||||
|
} elseif (isset($_POST['restore_names'])) {
|
||||||
|
try {
|
||||||
|
// Ensure table exists first
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS names (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
CardID VARCHAR(12) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) DEFAULT NULL
|
||||||
|
)");
|
||||||
|
// Now restore
|
||||||
|
$pdo->exec("TRUNCATE TABLE names; INSERT INTO names SELECT * FROM names_backup;");
|
||||||
|
$message = "<div class='msg-success'>'names' table restored and re-created!</div>";
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$message = "<div class='msg-error'>Error restoring 'names': " . $e->getMessage() . "</div>";
|
||||||
|
}
|
||||||
|
} elseif (isset($_POST['restore_times'])) {
|
||||||
|
try {
|
||||||
|
// Ensure table exists first
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS times (
|
||||||
|
|
||||||
|
UID VARCHAR(12) NOT NULL,
|
||||||
|
temp FLOAT,
|
||||||
|
timestamp DATETIME
|
||||||
|
)");
|
||||||
|
// Now restore
|
||||||
|
$pdo->exec("TRUNCATE TABLE times; INSERT INTO times SELECT * FROM times_backup;");
|
||||||
|
$message = "<div class='msg-success'>'times' table restored and re-created!</div>";
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$message = "<div class='msg-error'>Error restoring 'times': " . $e->getMessage() . "</div>";
|
||||||
|
}
|
||||||
|
} elseif (isset($_POST['action']) && $_POST['action'] === 'save_changes') {
|
||||||
|
// ONLY proceed if 'rows' exists and is an array
|
||||||
|
if (isset($_POST['rows']) && is_array($_POST['rows'])) {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
foreach ($_POST['rows'] as $pk_val => $columns) {
|
||||||
|
$setClauses = []; $params = [];
|
||||||
|
foreach ($columns as $col => $val) {
|
||||||
|
$setClauses[] = "`$col` = ?";
|
||||||
|
$params[] = trim($val);
|
||||||
|
}
|
||||||
|
$params[] = $pk_val;
|
||||||
|
$stmt = $pdo->prepare("UPDATE `$current_table` SET " . implode(", ", $setClauses) . " WHERE `$pk` = ?");
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
$message = "<div class='msg-success'>Changes saved successfully!</div>";
|
||||||
|
}
|
||||||
|
} elseif (isset($_POST['action']) && $_POST['action'] === 'delete_selected') {
|
||||||
|
if (!empty($_POST['selected_ids'])) {
|
||||||
|
$ids = $_POST['selected_ids'];
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM `$current_table` WHERE `$pk` IN ($placeholders)");
|
||||||
|
$stmt->execute($ids);
|
||||||
|
$message = "<div class='msg-success'>Rows deleted successfully!</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
$message = "<div class='msg-error'>Error: " . $e->getMessage() . "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FETCH DATA ---
|
||||||
|
$rows = [];
|
||||||
|
$columns = [];
|
||||||
|
$total_rows = 0;
|
||||||
|
$total_pages = 0;
|
||||||
|
|
||||||
|
// Only attempt to fetch data if the table actually exists
|
||||||
|
try {
|
||||||
|
// Check if table exists by trying to select one row
|
||||||
|
$pdo->query("SELECT 1 FROM `$current_table` LIMIT 1");
|
||||||
|
|
||||||
|
// If we reach here, table exists, proceed with standard queries
|
||||||
|
$colStmt = $pdo->query("SHOW COLUMNS FROM `$current_table`");
|
||||||
|
$columns = $colStmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$sort_col = (isset($_GET['sort']) && in_array($_GET['sort'], $columns)) ? $_GET['sort'] : $pk;
|
||||||
|
$sort_dir = (isset($_GET['dir']) && in_array($_GET['dir'], ['ASC', 'DESC'])) ? $_GET['dir'] : 'ASC';
|
||||||
|
|
||||||
|
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||||
|
$limit = 50;
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$countStmt = $pdo->query("SELECT COUNT(*) FROM `$current_table`");
|
||||||
|
$total_rows = $countStmt->fetchColumn();
|
||||||
|
$total_pages = ceil($total_rows / $limit);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM `$current_table` ORDER BY `$sort_col` $sort_dir LIMIT :limit OFFSET :offset");
|
||||||
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Table doesn't exist, leave $rows, $columns, etc., as empty arrays
|
||||||
|
// The page will render, but the table section will be empty/blank
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl($params = []) { return "?" . http_build_query(array_merge($_GET, $params)); }
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>PiClock DB Management</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
body { background: linear-gradient(180deg, #1c3344, #102a33, #000000); min-height: 100vh; padding: 40px 20px; color: #fff; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
.header-container { display: flex; justify-content: space-between; align-items: center; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.2); margin-bottom: 20px; }
|
||||||
|
.header-container h2 { margin: 0; color: #00bfa5; }
|
||||||
|
.header-buttons { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.btn { padding: 10px 16px; border-radius: 8px; font-weight: 600; font-size: 0.95rem; border: none; cursor: pointer; text-decoration: none; display: inline-block; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
|
||||||
|
.btn-fs { background-color: #4caf50; color: white; }
|
||||||
|
.btn-logout { background-color: #e53935; color: white; }
|
||||||
|
.btn-backup { background-color: #9c27b0; color: white; }
|
||||||
|
.btn-restore { background-color: #ff9800; color: white; }
|
||||||
|
.btn:hover { transform: translateY(-2px); filter: brightness(1.1); }
|
||||||
|
.tabs { display: flex; margin-bottom: 20px; gap: 10px; }
|
||||||
|
.tabs a { padding: 12px 24px; background: rgba(255,255,255,0.1); color: #fff; text-decoration: none; border-radius: 8px; font-weight: bold; transition: 0.3s; }
|
||||||
|
.tabs a:hover { background: rgba(255,255,255,0.2); }
|
||||||
|
.tabs a.active { background: #00897b; color: white; box-shadow: 0 4px 10px rgba(0,137,123,0.4); }
|
||||||
|
.msg-success { color: #81c784; background: rgba(129, 199, 132, 0.1); border: 1px solid #81c784; padding: 15px; border-radius: 8px; font-weight: bold; margin-bottom: 20px; }
|
||||||
|
.msg-error { color: #e57373; border: 1px solid #e57373; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
|
||||||
|
.card-table { background: #fff; border-radius: 12px; color: #333; overflow-x: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); margin-bottom: 20px;}
|
||||||
|
table { border-collapse: collapse; width: 100%; min-width: 800px; }
|
||||||
|
th { background-color: #00695c; color: white; padding: 16px; text-align: left; }
|
||||||
|
td { padding: 8px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: middle; }
|
||||||
|
tr:hover { background-color: #f5f5f5; }
|
||||||
|
th a { color: #ffffff; text-decoration: none; display: block; }
|
||||||
|
input[type="text"] { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; font-family: monospace; }
|
||||||
|
.bottom-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 15px; }
|
||||||
|
.btn-delete { background-color: #d32f2f; color: white; padding: 12px 20px; font-size: 1.1rem;}
|
||||||
|
.btn-save { background-color: #0288d1; color: white; padding: 12px 40px; font-size: 1.1rem; }
|
||||||
|
.pagination { display: flex; gap: 10px; align-items: center; background: rgba(255,255,255,0.1); padding: 10px 20px; border-radius: 8px; }
|
||||||
|
.pagination a { color: #4fc3f7; text-decoration: none; font-weight: bold; }
|
||||||
|
.footer { text-align: center; margin-top: 40px; color: rgba(255, 255, 255, 0.7); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-container">
|
||||||
|
<h2>🛠️ Developer DB Management</h2>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<form method="POST" style="display:inline;" onsubmit="return confirm('Backup ALL tables now? This overwrites existing backups.');">
|
||||||
|
<button type="submit" name="backup_all" class="btn btn-backup">Backup All</button></form>
|
||||||
|
<form method="POST" style="display:inline;" onsubmit="return confirm('WARNING: This drops current NAMES and restores from backup!');">
|
||||||
|
<button type="submit" name="restore_names" class="btn btn-restore">Restore Names</button></form>
|
||||||
|
<form method="POST" style="display:inline;" onsubmit="return confirm('WARNING: This drops current TIMES and restores from backup!');">
|
||||||
|
<button type="submit" name="restore_times" class="btn btn-restore">Restore Times</button></form>
|
||||||
|
<a href="?logout=1" class="btn btn-logout">Log Out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= $message ?>
|
||||||
|
<div class="tabs">
|
||||||
|
<a href="?table=names" class="<?= $current_table === 'names' ? 'active' : '' ?>">Names Table</a>
|
||||||
|
<a href="?table=times" class="<?= $current_table === 'times' ? 'active' : '' ?>">Times Table</a>
|
||||||
|
</div>
|
||||||
|
<form method="POST" id="dbForm">
|
||||||
|
<input type="hidden" name="action" id="formAction" value="save_changes">
|
||||||
|
<div class="card-table">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 5%; text-align: center;"><input type="checkbox" id="selectAll" onclick="toggleSelectAll()"></th>
|
||||||
|
<?php if (!empty($columns)): ?>
|
||||||
|
<?php foreach ($columns as $col): ?>
|
||||||
|
<th>
|
||||||
|
<?php
|
||||||
|
$next_dir = ($sort_col === $col && $sort_dir === 'ASC') ? 'DESC' : 'ASC';
|
||||||
|
$sortUrl = buildUrl(['sort' => $col, 'dir' => $next_dir]);
|
||||||
|
?>
|
||||||
|
<a href="<?= htmlspecialchars($sortUrl) ?>"><?= htmlspecialchars($col) ?> <?= ($sort_col === $col) ? ($sort_dir === 'ASC' ? '↑' : '↓') : '' ?></a>
|
||||||
|
</th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<th>No columns found (Table empty)</th>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center;"><input type="checkbox" name="selected_ids[]" class="rowCheckbox" value="<?= htmlspecialchars($row[$pk]) ?>"></td>
|
||||||
|
<?php foreach ($columns as $col): ?>
|
||||||
|
<td>
|
||||||
|
<?php if ($col === $pk): ?> <strong><?= htmlspecialchars($row[$col]) ?></strong>
|
||||||
|
<?php else: ?> <input type="text" name="rows[<?= htmlspecialchars($row[$pk]) ?>][<?= $col ?>]" value="<?= htmlspecialchars($row[$col]) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-actions">
|
||||||
|
<button type="button" class="btn btn-delete" onclick="submitForm('delete_selected')">Delete Selected</button>
|
||||||
|
<div class="pagination">
|
||||||
|
<span>Total: <?= $total_rows ?></span> |
|
||||||
|
<?php if ($page > 1): ?><a href="<?= buildUrl(['page' => $page - 1]) ?>">« Prev</a><?php endif; ?>
|
||||||
|
<span>Page <?= $page ?></span>
|
||||||
|
<?php if ($page < $total_pages): ?><a href="<?= buildUrl(['page' => $page + 1]) ?>">Next »</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-save" onclick="submitForm('save_changes')">Save All Edits</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="footer">
|
||||||
|
'PiClock' DB Manager <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2026
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Diaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function toggleSelectAll() { const cb = document.getElementById('selectAll'); document.querySelectorAll('.rowCheckbox').forEach(i => i.checked = cb.checked); }
|
||||||
|
function submitForm(action) { document.getElementById('formAction').value = action; document.getElementById('dbForm').submit(); }
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Pass the PHP timeout value into JavaScript to log out after inactivity
|
||||||
|
const TIMEOUT_DURATION = <?php echo DEV_TIMEOUT_SECONDS; ?> * 1000;
|
||||||
|
let logoutTimer;
|
||||||
|
|
||||||
|
function resetTimer() {
|
||||||
|
clearTimeout(logoutTimer);
|
||||||
|
logoutTimer = setTimeout(logoutUser, TIMEOUT_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutUser() {
|
||||||
|
window.location.href = "?logout=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
window.onload = resetTimer;
|
||||||
|
window.onmousemove = resetTimer;
|
||||||
|
window.onmousedown = resetTimer;
|
||||||
|
window.ontouchstart = resetTimer;
|
||||||
|
window.onclick = resetTimer;
|
||||||
|
window.onkeydown = resetTimer;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
$dbHost = 'db';
|
||||||
|
$dbName = 'PiClock';
|
||||||
|
$dbUser = 'root';
|
||||||
|
$dbPass = 'rootpassword';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4", $dbUser, $dbPass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("Database connection failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1. LOAD EMPLOYEES MAP **/
|
||||||
|
$employeeMap = [];
|
||||||
|
$stmt = $pdo->query("SELECT CardID, Name FROM names");
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
$cardKey = trim($row['CardID'] ?? '');
|
||||||
|
$nameVal = trim($row['Name'] ?? '');
|
||||||
|
if (!empty($cardKey)) {
|
||||||
|
$employeeMap[strtolower($cardKey)] = $nameVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2. FETCH AND MAP TIMESTAMPS (Directly from columns) **/
|
||||||
|
$combinedData = [];
|
||||||
|
// Select the columns directly
|
||||||
|
$stmt = $pdo->query("SELECT UID, timestamp FROM times");
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
$cardStr = strtolower(trim($row['UID']));
|
||||||
|
$timestamp = $row['timestamp'];
|
||||||
|
|
||||||
|
if (!empty($cardStr) && !empty($timestamp)) {
|
||||||
|
$timeObj = strtotime($timestamp);
|
||||||
|
$isMatched = isset($employeeMap[$cardStr]);
|
||||||
|
$employeeName = $isMatched ? $employeeMap[$cardStr] : 'Unknown Card';
|
||||||
|
|
||||||
|
$combinedData[] = [
|
||||||
|
'year' => (int)date('Y', $timeObj),
|
||||||
|
'iso_year' => (int)date('o', $timeObj), // Added ISO year for accurate week calculation
|
||||||
|
'week' => (int)date('W', $timeObj),
|
||||||
|
'date_only' => date('Y-m-d', $timeObj),
|
||||||
|
'formatted_day' => date('l - M j, Y', $timeObj),
|
||||||
|
'time_only' => date('H:i', $timeObj),
|
||||||
|
'card' => $cardStr,
|
||||||
|
'name' => $employeeName,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'is_matched' => $isMatched
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3. MULTI-COLUMN CHRONOLOGICAL SORTING **/
|
||||||
|
if (!empty($combinedData)) {
|
||||||
|
$dateOnly = array_column($combinedData, 'date_only');
|
||||||
|
$names = array_map('strtolower', array_column($combinedData, 'name'));
|
||||||
|
$timestamps = array_column($combinedData, 'timestamp');
|
||||||
|
array_multisort($dateOnly, SORT_DESC, $names, SORT_ASC, $timestamps, SORT_ASC, $combinedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4. GROUP DATA **/
|
||||||
|
$groupedData = [];
|
||||||
|
foreach ($combinedData as $row) {
|
||||||
|
// Included ISO year in the grouping key for accurate Week math
|
||||||
|
$dayKey = $row['date_only'] . '|' . $row['formatted_day'] . '|' . $row['week'] . '|' . $row['year'] . '|' . $row['iso_year'];
|
||||||
|
$empKey = $row['card'];
|
||||||
|
|
||||||
|
if (!isset($groupedData[$dayKey])) $groupedData[$dayKey] = [];
|
||||||
|
if (!isset($groupedData[$dayKey][$empKey])) {
|
||||||
|
$groupedData[$dayKey][$empKey] = [
|
||||||
|
'name' => $row['name'],
|
||||||
|
'card' => $row['card'],
|
||||||
|
'is_matched' => $row['is_matched'],
|
||||||
|
'punches' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$groupedData[$dayKey][$empKey]['punches'][] = $row['time_only'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5. OUTPUT HTML **/
|
||||||
|
foreach ($groupedData as $dayKey => $employees) {
|
||||||
|
list($dateOnly, $formattedDay, $weekNum, $yearNum, $isoYear) = explode('|', $dayKey);
|
||||||
|
|
||||||
|
// Calculate exact start and end dates for the week number
|
||||||
|
$dto = new DateTime();
|
||||||
|
$dto->setISODate((int)$isoYear, (int)$weekNum);
|
||||||
|
$weekStart = $dto->format('Y-m-d'); // Monday
|
||||||
|
$dto->modify('+6 days');
|
||||||
|
$weekEnd = $dto->format('Y-m-d'); // Sunday
|
||||||
|
|
||||||
|
echo '<div class="day-card" data-date="' . $dateOnly . '">';
|
||||||
|
echo '<div class="day-header">';
|
||||||
|
echo '<div class="day-title">' . htmlspecialchars($formattedDay) . '</div>';
|
||||||
|
echo '<div class="day-meta">';
|
||||||
|
echo '<span class="year" data-year="' . $yearNum . '">' . $yearNum . '</span> ';
|
||||||
|
// Injected the start/end dates into data attributes for JS to read
|
||||||
|
echo '<span class="week" data-start="' . $weekStart . '" data-end="' . $weekEnd . '">Wk ' . $weekNum . '</span>';
|
||||||
|
echo '</div></div>';
|
||||||
|
|
||||||
|
echo '<div class="day-content">';
|
||||||
|
foreach ($employees as $empCard => $empInfo) {
|
||||||
|
// Updated: Removed data-card attribute
|
||||||
|
echo '<div class="employee-row" data-name="' . htmlspecialchars(strtolower($empInfo['name'])) . '">';
|
||||||
|
|
||||||
|
// Employee Info Section
|
||||||
|
echo '<div class="employee-info">';
|
||||||
|
echo '<div class="avatar" style="' . (!$empInfo['is_matched'] ? 'background: #b0bec5;' : '') . '">' . htmlspecialchars(substr($empInfo['name'], 0, 1)) . '</div>';
|
||||||
|
|
||||||
|
// Name/Badge Section (ID removed from here)
|
||||||
|
echo '<div>' . ($empInfo['is_matched'] ? '<span class="employee-name">' . htmlspecialchars($empInfo['name']) . '</span>' : '<span class="badge-unknown">Unknown Card</span>') . '</div>';
|
||||||
|
echo '</div>'; // Closes .employee-info
|
||||||
|
|
||||||
|
// Punch Times Section
|
||||||
|
echo '<div class="punch-times">';
|
||||||
|
foreach ($empInfo['punches'] as $punch) { echo '<span class="time-badge">' . htmlspecialchars($punch) . '</span>'; }
|
||||||
|
echo '</div>'; // Closes .punch-times
|
||||||
|
|
||||||
|
echo '</div>'; // Closes .employee-row
|
||||||
|
}
|
||||||
|
echo '</div></div>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 496 B |
Binary file not shown.
|
After Width: | Height: | Size: 496 B |
+534
@@ -0,0 +1,534 @@
|
|||||||
|
<?php
|
||||||
|
// Start a secure session
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// 1. CHOOSE YOUR MANAGEMENT PASSWORD HERE:
|
||||||
|
define('STAFF_PASSWORD', '1111');
|
||||||
|
define('TIMEOUT_SECONDS', 86400); // Inactivity threshold
|
||||||
|
|
||||||
|
// Handle Explicit Logout
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
unset($_SESSION['authenticated']);
|
||||||
|
unset($_SESSION['last_activity']);
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Login Form Submission
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['x_secure_token'])) {
|
||||||
|
if ($_POST['x_secure_token'] === STAFF_PASSWORD) {
|
||||||
|
$_SESSION['authenticated'] = true;
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$login_error = "Incorrect password access denied.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-Side Inactivity Check
|
||||||
|
if (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) {
|
||||||
|
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > TIMEOUT_SECONDS)) {
|
||||||
|
unset($_SESSION['authenticated']);
|
||||||
|
unset($_SESSION['last_activity']);
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF'] . "?reason=timeout");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['reason']) && $_GET['reason'] === 'timeout') {
|
||||||
|
unset($_SESSION['authenticated']);
|
||||||
|
unset($_SESSION['last_activity']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is not authenticated, show the login gate
|
||||||
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
|
$display_msg = "Staff Access";
|
||||||
|
if (isset($_GET['reason']) && $_GET['reason'] === 'timeout') {
|
||||||
|
$login_error = "Logged out due to inactivity.";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Staff Login</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
touch-action: manipulation; /* Improves touch response */
|
||||||
|
}
|
||||||
|
/* Prevent iOS zoom on input focus */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="password"] {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
|
||||||
|
/* 1. Change body to flex-column */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(180deg, #1c3344, #102a33, #000000);
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center; /* Keeps it centered horizontally */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Push login box to the center of available space */
|
||||||
|
.login-box {
|
||||||
|
margin: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 40px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { margin-bottom: 25px; color: #0d47a1; font-size: 1.8rem; font-weight: 600; }
|
||||||
|
input[type="password"] { width: 100%; padding: 15px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; transition: all 0.3s; }
|
||||||
|
input[type="password"]:focus { outline: none; border-color: #1976d2; box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15); }
|
||||||
|
button { width: 100%; padding: 15px; background-color: #1976d2; color: white; border: none; border-radius: 8px; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
|
||||||
|
button:hover { background-color: #1565c0; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(25, 118, 210, 0.3); }
|
||||||
|
.error { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; font-weight: bold; margin-bottom: 20px; font-size: 0.9rem; border: 1px solid #ffcdd2; }
|
||||||
|
|
||||||
|
/* 3. Footer stays at the bottom */
|
||||||
|
.footer { text-align: center; padding-bottom: 20px; color: rgba(255, 255, 255, 0.7); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* Responsive adjustments for screens smaller than 600px */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
header h1 {
|
||||||
|
/* Scales between 1.5rem and 2.8rem depending on screen width */
|
||||||
|
font-size: clamp(1.5rem, 5vw, 2.8rem);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.employee-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.time-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.search-box {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="favicon.png" alt="PiClock">
|
||||||
|
<div class="login-box">
|
||||||
|
<h2><?= htmlspecialchars($display_msg) ?></h2>
|
||||||
|
<?php if (isset($login_error)): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($login_error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST" autocomplete="off" onsubmit="requestFS()">
|
||||||
|
<input type="password" name="x_secure_token" placeholder="Enter Password" autocomplete="new-password" required autofocus>
|
||||||
|
<button type="submit">Log In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
'PiClock' NFC Timestamp Viewer <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2025-<?php echo date('Y');?>
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Diaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>PiClock Dashboard</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
touch-action: manipulation; /* Improves touch response */
|
||||||
|
}
|
||||||
|
/* Prevent iOS zoom on input focus */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="password"] {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
body { background: linear-gradient(180deg, #1c3344, #102a33, #000000); min-height: 100dvh; padding: 30px 20px; color: #fff; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
header { text-align: center; padding: 5px 0; margin-bottom: 1px; }
|
||||||
|
header h1 {
|
||||||
|
/* Scales between 1.5rem and 2.8rem depending on screen width */
|
||||||
|
font-size: clamp(1.5rem, 5vw, 2.8rem);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
header p { font-size: 1.2rem; opacity: 0.9; }
|
||||||
|
|
||||||
|
.controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; flex-wrap: wrap; gap: 20px; }
|
||||||
|
.search-box { flex: 1; min-width: 150px; max-width: 250px; }
|
||||||
|
.search-box input { width: 100%; padding: 14px 20px; border-radius: 50px; border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.15); color: white; font-size: 1rem; }
|
||||||
|
.search-box input:focus { outline: none; background: rgba(255, 255, 255, 0.25); border-color: #64b5f6; }
|
||||||
|
|
||||||
|
.date-filters { display: flex; gap: 15px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.date-group { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.date-group label { font-size: 0.9rem; color: #bbdefb; font-weight: 500; }
|
||||||
|
.date-group input[type="date"] { padding: 11px 15px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.15); color: white; font-size: 0.95rem; font-family: inherit; color-scheme: dark; }
|
||||||
|
.date-group input[type="date"]:focus { outline: none; background: rgba(255, 255, 255, 0.25); border-color: #64b5f6; }
|
||||||
|
|
||||||
|
.timeline-container { display: flex; flex-direction: column; gap: 25px; margin-bottom: 20px; }
|
||||||
|
.day-card { background: rgba(255, 255, 255, 0.95); border-radius: 12px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
|
||||||
|
.day-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 25px; background: #1976d2; color: white; }
|
||||||
|
.day-title { font-size: 1.2rem; font-weight: 600; text-shadow: 0 1px 2px rgba(0,0,0,0.2); }
|
||||||
|
.day-meta { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.day-meta .year, .day-meta .week { padding: 4px 10px; border-radius: 4px; font-size: 0.85rem; background: rgba(255, 255, 255, 0.2); color: white; font-weight: 500; }
|
||||||
|
|
||||||
|
.day-content { display: flex; flex-direction: column; }
|
||||||
|
.employee-row { display: flex; justify-content: space-between; align-items: center; padding: 15px 25px; border-bottom: 1px solid #e0e0e0; gap: 20px; }
|
||||||
|
.employee-row:last-child { border-bottom: none; }
|
||||||
|
.employee-row:hover { background-color: #f5f9ff; }
|
||||||
|
|
||||||
|
.employee-info { display: flex; align-items: center; gap: 15px; }
|
||||||
|
.avatar { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(45deg, #2196f3, #64b5f6); display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 1.1rem; }
|
||||||
|
.employee-name { font-weight: 600; font-size: 1.05rem; color: #263238; }
|
||||||
|
.card-id-sub { font-size: 0.85rem; color: #666; margin-top: 2px; }
|
||||||
|
.badge-unknown { background-color: #ffebee; color: #c62828; padding: 4px 10px; border-radius: 12px; font-size: 0.85rem; font-weight: bold; border: 1px solid #ffcdd2; display: inline-block; }
|
||||||
|
code { background-color: #f0f1f4; padding: 2px 6px; border-radius: 4px; font-family: monospace; color: #1976d2; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.punch-times { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; max-width: 60%; }
|
||||||
|
.time-badge { background: #e3f2fd; color: #0d47a1; padding: 6px 14px; border-radius: 30px; font-size: 0.9rem; font-weight: 600; border: 1px solid #bbdefb; box-shadow: 0 2px 4px rgba(0,0,0,0.04); }
|
||||||
|
|
||||||
|
.table-header { display: flex; justify-content: space-between; padding: 15px 25px; background: #0d47a1; color: white; font-weight: 500; border-radius: 12px; margin-bottom: 15px; position: relative; z-index: 2; }
|
||||||
|
.table-header a, .table-header a:link, .table-header a:visited { color: #ffffffff !important; text-decoration: none; cursor: default; }
|
||||||
|
.footer { text-align: center; margin-top: 40px; color: rgba(255, 255, 255, 0.7); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ADDED: Visual Cues for Interactive filtering elements */
|
||||||
|
.day-title, .year, .week, .employee-name, .badge-unknown {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.day-title:hover, .year:hover, .week:hover, .employee-name:hover, .badge-unknown:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
/* Responsive adjustments for screens smaller than 600px */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
/* 1. Reclaim wasted screen space by reducing container padding */
|
||||||
|
body {
|
||||||
|
padding: 15px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Header and Search adjustments */
|
||||||
|
header h1 {
|
||||||
|
font-size: clamp(1.5rem, 6vw, 2.2rem) !important;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
header p { font-size: 1rem; }
|
||||||
|
.search-box { max-width: 100%; }
|
||||||
|
.controls { flex-direction: column; align-items: stretch; }
|
||||||
|
.date-filters { justify-content: space-between; }
|
||||||
|
|
||||||
|
/* 3. Stack the Table Header */
|
||||||
|
.table-header {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Stack the Day Header so dates don't wrap aggressively */
|
||||||
|
.day-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 15px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.day-title { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
/* 5. Stack the Employee Rows so time badges drop to a new line */
|
||||||
|
.employee-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 15px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.employee-info { width: 100%; }
|
||||||
|
|
||||||
|
/* 6. Allow time punches to take full width and align left under the name */
|
||||||
|
.punch-times {
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 55px; /* Indents them nicely under the avatar */
|
||||||
|
}
|
||||||
|
.time-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.employee-name { font-size: 1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>'PiClock' NFC System</h1>
|
||||||
|
<p>custom made Punchcard-System replacement</p>
|
||||||
|
</header>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="searchInput" placeholder="🔎 Search">
|
||||||
|
</div>
|
||||||
|
<div class="date-filters">
|
||||||
|
<div class="date-group">
|
||||||
|
<label for="startDate">From:</label>
|
||||||
|
<input type="date" id="startDate">
|
||||||
|
</div>
|
||||||
|
<div class="date-group">
|
||||||
|
<label for="endDate">To:</label>
|
||||||
|
<input type="date" id="endDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-header">
|
||||||
|
<div><a href="/staff_names.php" target="_blank">Grouped Shift Logs</a></div>
|
||||||
|
<div id="recordCounter">Loading NFC Card swipes...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-container" id="live-data">
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
'PiClock' NFC Timestamp Viewer <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2025-<?php echo date('Y');?>
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Diaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Safety net to re-enable fullscreen if lost
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
document.body.addEventListener('click', function() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(err => {});
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- INTERACTIVE CLICK LISTENERS ---
|
||||||
|
document.getElementById('live-data').addEventListener('click', function(e) {
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const startDateInput = document.getElementById('startDate');
|
||||||
|
const endDateInput = document.getElementById('endDate');
|
||||||
|
|
||||||
|
// 1. Employee Name or ID or Unknown Badge
|
||||||
|
const clickedName = e.target.closest('.employee-name') || e.target.closest('.badge-unknown');
|
||||||
|
if (clickedName) {
|
||||||
|
let textToSearch = clickedName.textContent.trim();
|
||||||
|
|
||||||
|
// Toggle off if already searching this exact text
|
||||||
|
if (searchInput.value === textToSearch) {
|
||||||
|
searchInput.value = '';
|
||||||
|
} else {
|
||||||
|
searchInput.value = textToSearch;
|
||||||
|
}
|
||||||
|
filterDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Day Title
|
||||||
|
const clickedDay = e.target.closest('.day-title');
|
||||||
|
if (clickedDay) {
|
||||||
|
const d = clickedDay.closest('.day-card').dataset.date;
|
||||||
|
|
||||||
|
// Toggle off if already filtered to this exact day
|
||||||
|
if (startDateInput.value === d && endDateInput.value === d) {
|
||||||
|
startDateInput.value = '';
|
||||||
|
endDateInput.value = '';
|
||||||
|
} else {
|
||||||
|
startDateInput.value = d;
|
||||||
|
endDateInput.value = d;
|
||||||
|
}
|
||||||
|
filterDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Year
|
||||||
|
const clickedYear = e.target.closest('.year');
|
||||||
|
if (clickedYear) {
|
||||||
|
const y = clickedYear.dataset.year;
|
||||||
|
const startY = y + '-01-01';
|
||||||
|
const endY = y + '-12-31';
|
||||||
|
|
||||||
|
// Toggle off if already filtered to this exact year
|
||||||
|
if (startDateInput.value === startY && endDateInput.value === endY) {
|
||||||
|
startDateInput.value = '';
|
||||||
|
endDateInput.value = '';
|
||||||
|
} else {
|
||||||
|
startDateInput.value = startY;
|
||||||
|
endDateInput.value = endY;
|
||||||
|
}
|
||||||
|
filterDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Week
|
||||||
|
const clickedWeek = e.target.closest('.week');
|
||||||
|
if (clickedWeek) {
|
||||||
|
const startWk = clickedWeek.dataset.start;
|
||||||
|
const endWk = clickedWeek.dataset.end;
|
||||||
|
|
||||||
|
// Toggle off if already filtered to this exact week
|
||||||
|
if (startDateInput.value === startWk && endDateInput.value === endWk) {
|
||||||
|
startDateInput.value = '';
|
||||||
|
endDateInput.value = '';
|
||||||
|
} else {
|
||||||
|
startDateInput.value = startWk;
|
||||||
|
endDateInput.value = endWk;
|
||||||
|
}
|
||||||
|
filterDashboard();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- FILTER LOGIC ---
|
||||||
|
function filterDashboard() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||||
|
const startDate = document.getElementById('startDate').value;
|
||||||
|
const endDate = document.getElementById('endDate').value;
|
||||||
|
|
||||||
|
const dayCards = document.querySelectorAll('.day-card');
|
||||||
|
let totalVisiblePunches = 0;
|
||||||
|
|
||||||
|
dayCards.forEach(card => {
|
||||||
|
const dayDate = card.getAttribute('data-date');
|
||||||
|
|
||||||
|
let dateMatches = true;
|
||||||
|
if (startDate && dayDate < startDate) dateMatches = false;
|
||||||
|
if (endDate && dayDate > endDate) dateMatches = false;
|
||||||
|
|
||||||
|
if (!dateMatches) {
|
||||||
|
card.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleEmployeesInDay = 0;
|
||||||
|
const rows = card.querySelectorAll('.employee-row');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const empName = row.getAttribute('data-name');
|
||||||
|
|
||||||
|
// Updated search logic
|
||||||
|
const textMatches = empName.includes(searchTerm) ||
|
||||||
|
(searchTerm === 'unknown card' && row.querySelector('.badge-unknown'));
|
||||||
|
|
||||||
|
if (textMatches) {
|
||||||
|
row.style.display = '';
|
||||||
|
visibleEmployeesInDay++;
|
||||||
|
totalVisiblePunches += row.querySelectorAll('.time-badge').length;
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visibleEmployeesInDay > 0) {
|
||||||
|
card.style.display = '';
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recordCounter').textContent = 'Total: ' + totalVisiblePunches + ' swipes';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchInput').addEventListener('input', filterDashboard);
|
||||||
|
document.getElementById('startDate').addEventListener('change', filterDashboard);
|
||||||
|
document.getElementById('endDate').addEventListener('change', filterDashboard);
|
||||||
|
|
||||||
|
|
||||||
|
// --- LIVE UPDATE LOGIC ---
|
||||||
|
let currentVersion = '';
|
||||||
|
|
||||||
|
// Fetches the HTML from the backend and updates the container
|
||||||
|
async function fetchTableData() {
|
||||||
|
try {
|
||||||
|
// Ensure this points to the new PHP script you created in the first step
|
||||||
|
const response = await fetch('get-table-data.php?t=' + Date.now());
|
||||||
|
if (!response.ok) return;
|
||||||
|
const htmlRows = await response.text();
|
||||||
|
|
||||||
|
// Inject the new HTML
|
||||||
|
document.getElementById('live-data').innerHTML = htmlRows;
|
||||||
|
|
||||||
|
// Re-run the filter immediately so new data obeys active search/dates
|
||||||
|
filterDashboard();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching table data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks the database for new row counts
|
||||||
|
async function checkServerForUpdates() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('check_update.php?t=' + Date.now());
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Update background check failed with status:', response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = await response.text();
|
||||||
|
|
||||||
|
// Initial load scenario
|
||||||
|
if (!currentVersion) {
|
||||||
|
currentVersion = latestVersion;
|
||||||
|
fetchTableData(); // Fetch the data for the first time
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a change happened in the MySQL Database
|
||||||
|
if (latestVersion !== currentVersion) {
|
||||||
|
console.log('Database change detected! Fetching new data...');
|
||||||
|
currentVersion = latestVersion;
|
||||||
|
fetchTableData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates every 3000 milliseconds (3 seconds)
|
||||||
|
checkServerForUpdates();
|
||||||
|
setInterval(checkServerForUpdates, 3000);
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Pass the PHP timeout value into JavaScript to log out after inactivity
|
||||||
|
const TIMEOUT_DURATION = <?php echo TIMEOUT_SECONDS; ?> * 1000;
|
||||||
|
let logoutTimer;
|
||||||
|
|
||||||
|
function resetTimer() {
|
||||||
|
clearTimeout(logoutTimer);
|
||||||
|
logoutTimer = setTimeout(logoutUser, TIMEOUT_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutUser() {
|
||||||
|
window.location.href = "?logout=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
window.onload = resetTimer;
|
||||||
|
window.onmousemove = resetTimer;
|
||||||
|
window.onmousedown = resetTimer;
|
||||||
|
window.ontouchstart = resetTimer;
|
||||||
|
window.onclick = resetTimer;
|
||||||
|
window.onkeydown = resetTimer;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
date_default_timezone_set('Europe/London');
|
||||||
|
$log_file = 'nfc_debug.log';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$raw_input = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw_input, true);
|
||||||
|
$raw_uid = $data['uid'] ?? null;
|
||||||
|
|
||||||
|
if (!$raw_uid) { die("No UID"); }
|
||||||
|
|
||||||
|
// Normalize UID
|
||||||
|
$clean_uid = "";
|
||||||
|
foreach (explode(':', $raw_uid) as $part) { $clean_uid .= dechex(hexdec($part)); }
|
||||||
|
|
||||||
|
// --- SERVER-SIDE DEBOUNCE LOGIC ---
|
||||||
|
$debounce_file = sys_get_temp_dir() . '/nfc_swipe_debounce_' . $clean_uid . '.txt';
|
||||||
|
$debounce_seconds = 60;
|
||||||
|
$current_time = time();
|
||||||
|
|
||||||
|
if (file_exists($debounce_file)) {
|
||||||
|
$last_time = (int)file_get_contents($debounce_file);
|
||||||
|
if (($current_time - $last_time) < $debounce_seconds) {
|
||||||
|
// Return a safe JSON response so the front-end doesn't crash if this catches a glitch
|
||||||
|
echo json_encode(['status' => 'ignored', 'name' => 'Already Logged', 'time' => date('H:i:s')]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_put_contents($debounce_file, $current_time);
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = new PDO("mysql:host=db;dbname=PiClock", "root", "rootpassword");
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// --- AUTOMATIC TABLE CREATION ---
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS names (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
CardID VARCHAR(12) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) DEFAULT NULL
|
||||||
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS times (
|
||||||
|
UID VARCHAR(12) NOT NULL,
|
||||||
|
temp FLOAT,
|
||||||
|
timestamp DATETIME
|
||||||
|
)");
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
// 1. Check if the card already exists in the names table
|
||||||
|
$stmt = $db->prepare("SELECT name FROM names WHERE CardID = ?");
|
||||||
|
$stmt->execute([$clean_uid]);
|
||||||
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
// Card is known, grab the existing name
|
||||||
|
$name = $user['name'];
|
||||||
|
} else {
|
||||||
|
// Card is UNKNOWN. Auto-register it.
|
||||||
|
$maxStmt = $db->query("SELECT MAX(id) FROM names");
|
||||||
|
$max_id = $maxStmt->fetchColumn();
|
||||||
|
// If the table is empty, start at 1, otherwise use the highest + 1
|
||||||
|
$next_id = ($max_id !== null) ? ($max_id + 1) : 1;
|
||||||
|
|
||||||
|
// Format the name to zz_UNASSIGNED-XX
|
||||||
|
$name = sprintf("zz_UNASSIGNED-%02d", $next_id);
|
||||||
|
|
||||||
|
// Insert the new card into the database
|
||||||
|
$insertStmt = $db->prepare("INSERT INTO names (CardID, name) VALUES (?, ?)");
|
||||||
|
$insertStmt->execute([$clean_uid, $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Log the swipe in the times table
|
||||||
|
$stmt = $db->prepare("INSERT INTO times (UID, temp, timestamp) VALUES (?, 56.0, ?)");
|
||||||
|
$stmt->execute([$clean_uid, date('Y-m-d H:i:s')]);
|
||||||
|
|
||||||
|
// 3. Send the result back to the front-end
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['status' => 'success', 'name' => $name, 'time' => date('H:i:s')]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
file_put_contents($log_file, "DB ERROR: " . $e->getMessage() . PHP_EOL, FILE_APPEND);
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
$db = new PDO("mysql:host=db;dbname=PiClock", "root", "rootpassword");
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
// Ensure tables exist
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS names (id INT AUTO_INCREMENT PRIMARY KEY, CardID VARCHAR(12) NOT NULL UNIQUE, name VARCHAR(100) DEFAULT NULL)");
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS times (UID VARCHAR(12) NOT NULL, temp FLOAT, timestamp DATETIME)");
|
||||||
|
|
||||||
|
// --- BACKUP FUNCTION ---
|
||||||
|
if (isset($_POST['backup'])) {
|
||||||
|
header('Content-Type: text/csv');
|
||||||
|
header('Content-Disposition: attachment; filename="backup_' . date('Y-m-d') . '.csv"');
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
// Example: Exporting names
|
||||||
|
$res = $db->query("SELECT * FROM names");
|
||||||
|
while ($row = $res->fetch(PDO::FETCH_ASSOC)) { fputcsv($out, $row); }
|
||||||
|
fclose($out);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IMPORT FUNCTION ---
|
||||||
|
if (isset($_POST['import']) && isset($_FILES['csv_file'])) {
|
||||||
|
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
|
||||||
|
while (($data = fgetcsv($handle)) !== false) {
|
||||||
|
if (count($data) >= 2) {
|
||||||
|
// Using INSERT IGNORE to prevent duplicate errors
|
||||||
|
$stmt = $db->prepare("INSERT IGNORE INTO names (CardID, name) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$data[0], $data[1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
echo "<p>Import completed successfully.</p>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="file" name="csv_file" accept=".csv" required>
|
||||||
|
<button type="submit" name="import">Load & Merge CSV</button>
|
||||||
|
<button type="submit" name="backup">Backup Current DB to CSV</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "NFC Scanner",
|
||||||
|
"short_name": "Scanner",
|
||||||
|
"start_url": "scanner.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1e3c72",
|
||||||
|
"theme_color": "#1e3c72",
|
||||||
|
"icons": []
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
CardID,EmployeeName
|
||||||
|
n/a,LOGOUT
|
||||||
|
4b6f6baa3f90,"Maximilian Q."
|
||||||
|
4fa7baa3f90,"Angelo P."
|
||||||
|
4afebaa3f91,"Javier S."
|
||||||
|
474fcbaa3f90,"Peter M."
|
||||||
|
46f2baa3f94,"John S."
|
||||||
|
420a3baa3f90,"Manolo F."
|
||||||
|
458d8baa3f90,"Francis X."
|
||||||
|
4fce5baa3f90,"Xavier F."
|
||||||
|
4a1e2baa3f90,"Martha D."
|
||||||
|
4d3adbaa3f90,"Edu A."
|
||||||
|
482d8baa3f90,"Jesus P."
|
||||||
|
42cf0baa3f91,"David M."
|
||||||
|
49125baa3f90,"Cristina S."
|
||||||
|
4fbedbaa3f90,"Steven H."
|
||||||
|
425edbaa3f91,"Joshua K."
|
||||||
|
4e90baa3f94,"Jacky C."
|
||||||
|
4eaeabaa3f90,"Daniel F."
|
||||||
|
45fc0baa3f91,"Alexander T."
|
||||||
|
448efbaa3f91,"Dominikas Z."
|
||||||
|
442f4baa3f91,"Andreas M."
|
||||||
|
4f79bbaa3f90,"Gustav G."
|
||||||
|
482c6baa3f91,_INACTIVE21
|
||||||
|
46cfabaa3f90,_INACTIVE22
|
||||||
|
4ccabaa3f91,_INACTIVE23
|
||||||
|
427eabaa3f91,_INACTIVE24
|
||||||
|
4109bbaa3f91,_INACTIVE25
|
||||||
|
47bcdbaa3f90,_INACTIVE26
|
||||||
|
44abfbaa3f90,_INACTIVE27
|
||||||
|
477d4baa3f90,_INACTIVE28
|
||||||
|
4bbf6baa3f90,_INACTIVE29
|
||||||
|
42b0baa3f91,_INACTIVE30
|
||||||
|
4caf6baa3f90,_INACTIVE31
|
||||||
|
4b6c9baa3f90,"Danielle F."
|
||||||
|
41defbaa3f91,"Luke S."
|
||||||
|
4ef55baa3f90,_INACTIVE34
|
||||||
|
49bcdbaa3f90,_INACTIVE35
|
||||||
|
471b5baa3f91,_INACTIVE36
|
||||||
|
464f7baa3f90,_INACTIVE37
|
||||||
|
487d4baa3f90,_INACTIVE38
|
||||||
|
423f7baa3f91,_INACTIVE39
|
||||||
|
4839abaa3f91,_INACTIVE40
|
||||||
|
4c0d5baa3f90,_INACTIVE41
|
||||||
|
4629bbaa3f91,_INACTIVE42
|
||||||
|
49cf6baa3f90,_INACTIVE43
|
||||||
|
446a3baa3f90,_INACTIVE44
|
||||||
|
4bed7baa3f90,_INACTIVE45
|
||||||
|
4e0d4baa3f90,_INACTIVE46
|
||||||
|
43fcbbaa3f91,_INACTIVE47
|
||||||
|
45dd9baa3f91,_INACTIVE48
|
||||||
|
481d8baa3f90,_INACTIVE49
|
||||||
|
434c6baa3f91,_INACTIVE50
|
||||||
|
4b76baa3f94,_INACTIVE51
|
||||||
|
41517baa3f91,_INACTIVE52
|
||||||
|
4b7f5baa3f90,_INACTIVE53
|
||||||
|
4b5d0baa3f90,"Luis H."
|
||||||
|
457baa3f95,_INACTIVE55
|
||||||
|
4eb1baa3f94,_INACTIVE56
|
||||||
|
4e7c8baa3f90,_INACTIVE57
|
||||||
|
487c2baa3f90,"Francisco J."
|
||||||
|
410f4baa3f91,_INACTIVE59
|
||||||
|
46eacbaa3f91,_INACTIVE60
|
||||||
|
4ceeabaa3f90,_INACTIVE61
|
||||||
|
401baa3f95,_INACTIVE62
|
||||||
|
4dad6baa3f90,_INACTIVE63
|
||||||
|
4cecbaa3f94,_INACTIVE64
|
||||||
|
41ba7baa3f90,_INACTIVE65
|
||||||
|
43a9cbaa3f91,_INACTIVE66
|
||||||
|
48c6baa3f94,_INACTIVE67
|
||||||
|
4841cbaa3f90,_INACTIVE68
|
||||||
|
467fdbaa3f90,_INACTIVE69
|
||||||
|
4865baa3f94,_INACTIVE70
|
||||||
|
44ed8baa3f90,_INACTIVE71
|
||||||
|
4e9efbaa3f90,_INACTIVE72
|
||||||
|
4a0d7baa3f90,_INACTIVE73
|
||||||
|
45a9bbaa3f91,_INACTIVE75
|
||||||
|
458d1baa3f91,_INACTIVE76
|
||||||
|
4eff3baa3f90,_INACTIVE77
|
||||||
|
47ffdbaa3f90,_INACTIVE78
|
||||||
|
4c6adbaa3f90,_INACTIVE79
|
||||||
|
4e30baa3f91,"Mikhail G."
|
||||||
|
446a7baa3f90,"Donald T."
|
||||||
|
47a9fbaa3f90,_INACTIVE82
|
||||||
|
426edbaa3f91,_INACTIVE83
|
||||||
|
42abfbaa3f90,_INACTIVE84
|
||||||
|
483fcbaa3f90,_INACTIVE85
|
||||||
|
4d9efbaa3f90,_INACTIVE86
|
||||||
|
4a1c0baa3f90,_INACTIVE87
|
||||||
|
44ff8baa3f90,_INACTIVE88
|
||||||
|
458f7baa3f90,_INACTIVE89
|
||||||
|
4c1d7baa3f90,_INACTIVE90
|
||||||
|
4c6f7baa3f90,_INACTIVE91
|
||||||
|
484aabaa3f90,_INACTIVE92
|
||||||
|
4139cbaa3f91,_INACTIVE93
|
||||||
|
482adbaa3f90,_INACTIVE94
|
||||||
|
490acbaa3f90,_INACTIVE95
|
||||||
|
46f3baa3f91,_INACTIVE96
|
||||||
|
465fdbaa3f90,_INACTIVE97
|
||||||
|
4a68baa3f94,_INACTIVE98
|
||||||
|
458d8baa3f91,_INACTIVE99
|
||||||
|
xxxxxxxxxxxx,_NOTFOUND
|
||||||
|
@@ -0,0 +1,174 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Network Audit Tracker</title>
|
||||||
|
<link rel="icon" href="habiwan.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; background-color: #1e1e2e; color: #cdd6f4; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h2 { border-bottom: 2px solid #313244; padding-bottom: 0.5rem; }
|
||||||
|
.controls { margin-top: 1rem; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.form-group { display: flex; gap: 10px; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||||
|
input[type="text"], select, button { padding: 8px 12px; background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; }
|
||||||
|
input.edit-input { width: 90%; }
|
||||||
|
button { font-weight: bold; cursor: pointer; border: none; transition: 0.2s; }
|
||||||
|
button:hover { opacity: 0.8; }
|
||||||
|
button.action-btn { background: #89b4fa; color: #11111b; }
|
||||||
|
button.delete-btn { background: #f38ba8; color: #11111b; }
|
||||||
|
button.export-btn { background: #a6e3a1; color: #11111b; }
|
||||||
|
button.import-btn { background: #f9e2af; color: #11111b; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||||
|
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #313244; }
|
||||||
|
th { cursor: pointer; user-select: none; }
|
||||||
|
/* Disable cursor for the non-sortable # column */
|
||||||
|
th.static { cursor: default; }
|
||||||
|
.badge { padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: bold; }
|
||||||
|
.type-infrastructure { background: #f38ba8; color: #11111b; }
|
||||||
|
.type-service { background: #fab387; color: #11111b; }
|
||||||
|
.type-client { background: #a6e3a1; color: #11111b; }
|
||||||
|
.type-transient { background: #cba6f7; color: #11111b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Network Audit Tracker</h1>
|
||||||
|
<p>Device Count: <span id="deviceCount">0</span></p>
|
||||||
|
|
||||||
|
<form class="form-group" id="addForm">
|
||||||
|
<input type="text" id="hostname" placeholder="Hostname" required>
|
||||||
|
<input type="text" id="ip" placeholder="IP" required>
|
||||||
|
<input type="text" id="mac" placeholder="MAC Address" required>
|
||||||
|
<select id="type">
|
||||||
|
<option value="Infrastructure">Infrastructure</option>
|
||||||
|
<option value="Service">Service</option>
|
||||||
|
<option value="Client">Client</option>
|
||||||
|
<option value="Transient">Transient</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="action-btn">Add Device</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="selectAll" onclick="toggleSelectAll()"></th>
|
||||||
|
<th class="static">#</th>
|
||||||
|
<th onclick="sortData('hostname')">Hostname ↕️</th>
|
||||||
|
<th onclick="sortData('ip')">IP Address ↕️</th>
|
||||||
|
<th onclick="sortData('mac')">MAC Address ↕️</th>
|
||||||
|
<th onclick="sortData('type')">Type ↕️</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="export-btn" onclick="exportToCSV()">Export CSV</button>
|
||||||
|
<button class="import-btn" onclick="document.getElementById('importFile').click()">Import CSV</button>
|
||||||
|
<button class="delete-btn" onclick="deleteSelected()">Delete Selected</button>
|
||||||
|
<input type="file" id="importFile" accept=".csv" style="display:none" onchange="importCSV(event)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let devices = JSON.parse(localStorage.getItem('networkAuditData')) || [];
|
||||||
|
let sortAscending = true;
|
||||||
|
let currentSortColumn = '';
|
||||||
|
const tableBody = document.getElementById('tableBody');
|
||||||
|
const deviceCount = document.getElementById('deviceCount');
|
||||||
|
|
||||||
|
function ipToNumber(ip) {
|
||||||
|
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
devices.forEach((device, index) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const isEditing = device.isEditing;
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="checkbox" ${device.selected ? 'checked' : ''} onchange="toggleSelect(${device.id})"></td>
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
<td>${isEditing ? `<input type="text" class="edit-input" value="${device.hostname}" onchange="updateField(${device.id}, 'hostname', this.value)">` : device.hostname}</td>
|
||||||
|
<td>${isEditing ? `<input type="text" class="edit-input" value="${device.ip}" onchange="updateField(${device.id}, 'ip', this.value)">` : device.ip}</td>
|
||||||
|
<td>${isEditing ? `<input type="text" class="edit-input" value="${device.mac}" onchange="updateField(${device.id}, 'mac', this.value)">` : device.mac}</td>
|
||||||
|
<td>${isEditing ? `<select onchange="updateField(${device.id}, 'type', this.value)"><option value="Infrastructure" ${device.type==='Infrastructure'?'selected':''}>Infrastructure</option><option value="Service" ${device.type==='Service'?'selected':''}>Service</option><option value="Client" ${device.type==='Client'?'selected':''}>Client</option><option value="Transient" ${device.type==='Transient'?'selected':''}>Transient</option></select>` : `<span class="badge type-${device.type.toLowerCase()}">${device.type}</span>`}</td>
|
||||||
|
<td><button class="action-btn" onclick="toggleEdit(${device.id})">${isEditing ? '💾' : '✏️'}</button></td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
deviceCount.innerText = devices.length;
|
||||||
|
localStorage.setItem('networkAuditData', JSON.stringify(devices));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEdit(id) {
|
||||||
|
const dev = devices.find(d => d.id === id);
|
||||||
|
dev.isEditing = !dev.isEditing;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(id, field, value) {
|
||||||
|
const dev = devices.find(d => d.id === id);
|
||||||
|
dev[field] = value;
|
||||||
|
localStorage.setItem('networkAuditData', JSON.stringify(devices));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id) {
|
||||||
|
const dev = devices.find(d => d.id === id);
|
||||||
|
dev.selected = !dev.selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
const allChecked = document.getElementById('selectAll').checked;
|
||||||
|
devices.forEach(d => d.selected = allChecked);
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelected() {
|
||||||
|
devices = devices.filter(d => !d.selected);
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortData(key) {
|
||||||
|
if (currentSortColumn === key) sortAscending = !sortAscending;
|
||||||
|
else { currentSortColumn = key; sortAscending = true; }
|
||||||
|
devices.sort((a, b) => {
|
||||||
|
let valA = key === 'ip' ? ipToNumber(a[key]) : a[key].toLowerCase();
|
||||||
|
let valB = key === 'ip' ? ipToNumber(b[key]) : b[key].toLowerCase();
|
||||||
|
return sortAscending ? (valA < valB ? -1 : 1) : (valA > valB ? -1 : 1);
|
||||||
|
});
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToCSV() {
|
||||||
|
let csvContent = "data:text/csv;charset=utf-8,Hostname,IP,MAC,Type\n" + devices.map(d => `${d.hostname},${d.ip},${d.mac},${d.type}`).join("\n");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", encodeURI(csvContent));
|
||||||
|
link.setAttribute("download", "network_audit.csv");
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function importCSV(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
e.target.result.split('\n').slice(1).forEach(line => {
|
||||||
|
const [hostname, ip, mac, type] = line.split(',');
|
||||||
|
if (hostname) devices.push({ id: Date.now() + Math.random(), hostname: hostname.trim(), ip: ip.trim(), mac: mac.trim(), type: type.trim(), isEditing: false, selected: false });
|
||||||
|
});
|
||||||
|
renderTable();
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
devices.push({ id: Date.now(), hostname: document.getElementById('hostname').value, ip: document.getElementById('ip').value, mac: document.getElementById('mac').value.toUpperCase(), type: document.getElementById('type').value, isEditing: false, selected: false });
|
||||||
|
renderTable();
|
||||||
|
document.getElementById('addForm').reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Hostname,IP,MAC,Type
|
||||||
|
vodafone UltraHub 7,192.168.183.1,18:EF:C0:12:84:E0,Infrastructure
|
||||||
|
mandalorian (Synology),192.168.183.3,00:11:32:97:21:51,Service
|
||||||
|
Zyxel Switch,192.168.183.9,98:0D:67:08:89:0E,Infrastructure
|
||||||
|
habiNote10,192.168.183.51,8C:B8:4A:20:04:5C,Client
|
||||||
|
habiSufaceDuo2,192.168.183.55,A0:4A:5E:CF:98:B2,Client
|
||||||
|
habiPi5,192.168.183.100,D8:3A:DD:F4:C5:57,Service
|
||||||
|
habiROG,192.168.183.101,A8:5E:45:BF:31:4C,Client
|
||||||
|
habiPlex (minimac),192.168.183.102,5C:1B:F4:7C:55:FD,Service
|
||||||
|
habiDell (Commodore),192.168.183.104,E4:F8:9C:8B:8B:6B,Client
|
||||||
|
habiClevo,192.168.183.106,80:FA:5B:26:7F:EF,Client
|
||||||
|
habiMiniPC (TV),192.168.183.110,DE:96:5D:C7:9E:1E,Service
|
||||||
|
DCP-L2530DWE67C,192.168.183.116,8C:C8:4B:1D:E6:7C,Infrastructure
|
||||||
|
habiPhoneSE,192.168.183.128,E0:5F:45:51:A7:E8,Client
|
||||||
|
Nintendo Switch,192.168.183.130,40:44:F7:D0:74:2D,Client
|
||||||
|
RP761B_0538 WiFi Booster,192.168.183.138,94:F7:BE:E9:35:C0,Infrastructure
|
||||||
|
Amazon,192.168.183.142,94:3A:91:C5:5E:82,Client
|
||||||
|
habiMac,192.168.183.144,DE:3D:DC:CD:20:3B,Client
|
||||||
|
Sony PlayStation,192.168.183.152,B4:0A:D8:79:71:B2,Client
|
||||||
|
Proxmox gateway,192.168.183.163,BC:24:11:AA:AB:98,Transient
|
||||||
|
l337 Raspberry Pi,192.168.183.170,DC:A6:32:0F:8F:6A,Client
|
||||||
|
habiTv,192.168.183.171,A8:51:AB:A7:85:37,Client
|
||||||
|
iPhone,192.168.183.187,E2:F4:11:DF:4A:C0,Client
|
||||||
|
palpatine Apple TV,192.168.183.192,60:65:25:E9:F3:AE,Client
|
||||||
|
StarDestroyer hp PVE,192.168.183.200,9C:8E:99:5B:D1:04,Client
|
||||||
|
Unknown,192.168.183.230,02:CB:89:57:0C:B4,Client
|
||||||
|
Unknown,192.168.183.231,02:08:72:A7:A2:7A,Client
|
||||||
|
Unknown,192.168.183.232,02:A4:51:AA:D9:57,Client
|
||||||
|
Unknown,192.168.183.233,02:80:35:82:FD:82,Client
|
||||||
|
Unknown,192.168.183.235,02:4B:C7:90:45:90,Client
|
||||||
|
HP Integrated Lights-Out,192.168.183.252,9C:8E:99:5B:D1:08,Service
|
||||||
|
@@ -0,0 +1,24 @@
|
|||||||
|
--- Request at 2026-07-14 09:01:14 ---
|
||||||
|
Raw Input: {"uid":"04:91:25:ba:a3:0f:90"}
|
||||||
|
Python-formatted UID: 49125baa3f90 (Length: 12)
|
||||||
|
Success: Database insert complete.
|
||||||
|
--- Request at 2026-07-14 09:02:01 ---
|
||||||
|
Raw Input: {"uid":"04:91:25:ba:a3:0f:90"}
|
||||||
|
Python-formatted UID: 49125baa3f90 (Length: 12)
|
||||||
|
Success: Database insert complete.
|
||||||
|
--- Request at 2026-07-14 09:02:17 ---
|
||||||
|
Raw Input: {"uid":"04:25:ed:ba:a3:0f:91"}
|
||||||
|
Python-formatted UID: 425edbaa3f91 (Length: 12)
|
||||||
|
Success: Database insert complete.
|
||||||
|
--- Request at 2026-07-14 09:02:28 ---
|
||||||
|
Raw Input: {"uid":"04:ea:ea:ba:a3:0f:90"}
|
||||||
|
Python-formatted UID: 4eaeabaa3f90 (Length: 12)
|
||||||
|
Success: Database insert complete.
|
||||||
|
--- Request at 2026-07-14 09:02:31 ---
|
||||||
|
Raw Input: {"uid":"04:6f:02:ba:a3:0f:94"}
|
||||||
|
Python-formatted UID: 46f2baa3f94 (Length: 11)
|
||||||
|
Success: Database insert complete.
|
||||||
|
--- Request at 2026-07-14 09:02:35 ---
|
||||||
|
Raw Input: {"uid":"04:58:d8:ba:a3:0f:90"}
|
||||||
|
Python-formatted UID: 458d8baa3f90 (Length: 12)
|
||||||
|
Success: Database insert complete.
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>PiClock NFC Scanner</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<head>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<!-- Import digital clock font -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* Global Reset */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Force full screen height */
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||||
|
background-attachment: fixed; /* Keeps gradient solid */
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex container */
|
||||||
|
#display-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 50px; /* Space for footer */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard styling for the Employee Name */
|
||||||
|
.name {
|
||||||
|
font-size: clamp(1.5rem, 8vw, 5rem);
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
min-height: 1.2em;
|
||||||
|
width: 95%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Giant Dark Green Digital Clock Mode */
|
||||||
|
.clock-box {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
background: linear-gradient(145deg, #1b5e20, #0d3312);
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 30px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
padding: 4vh 2vw;
|
||||||
|
font-size: clamp(3.5rem, 18vw, 14rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: normal;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.6), inset 0 0 15px rgba(255,255,255,0.1);
|
||||||
|
text-shadow: 0 5px 15px rgba(0,0,0,0.5);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: clamp(1rem, 4vw, 2.5rem);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 20px;
|
||||||
|
min-height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-radius: 15px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e3c72;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 40px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
button:active { transform: scale(0.95); }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="favicon.png" alt="PiClock">
|
||||||
|
<div id="display-area">
|
||||||
|
<div id="name-display" class="name"></div>
|
||||||
|
<div id="time-display" class="time"></div>
|
||||||
|
<button id="scan-btn" onclick="startScan()">Start NFC Scanner</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Android Chrome NFC Scanner for 'PiClock' <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2026
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Díaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let clockInterval;
|
||||||
|
let resetTimeout;
|
||||||
|
|
||||||
|
// --- Debounce & Data Trackers ---
|
||||||
|
const lastSwipeTimes = new Map();
|
||||||
|
const swipeNames = new Map(); // Remembers the person's name locally
|
||||||
|
const swipeLogTimes = new Map(); // Remembers the exact time they logged
|
||||||
|
const DEBOUNCE_MS = 60000;
|
||||||
|
|
||||||
|
// Function to display the live clock in HH:MM format
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
const nameDisplay = document.getElementById('name-display');
|
||||||
|
nameDisplay.innerText = `${hh}:${mm}`;
|
||||||
|
nameDisplay.className = 'name clock-box';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScan() {
|
||||||
|
const btn = document.getElementById('scan-btn');
|
||||||
|
if (document.documentElement.requestFullscreen) {
|
||||||
|
document.documentElement.requestFullscreen().catch(err => {
|
||||||
|
console.log(`Error attempting to enable full-screen: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nameDisplay = document.getElementById('name-display');
|
||||||
|
const timeDisplay = document.getElementById('time-display');
|
||||||
|
|
||||||
|
if (!('NDEFReader' in window)) {
|
||||||
|
nameDisplay.innerText = "Browser Error";
|
||||||
|
timeDisplay.innerText = "NFC not supported or not on HTTPS.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ndef = new NDEFReader();
|
||||||
|
await ndef.scan();
|
||||||
|
|
||||||
|
btn.style.display = 'none';
|
||||||
|
|
||||||
|
updateClock();
|
||||||
|
clockInterval = setInterval(updateClock, 1000);
|
||||||
|
timeDisplay.innerText = "";
|
||||||
|
|
||||||
|
ndef.onreading = async (event) => {
|
||||||
|
const uid = event.serialNumber;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// --- Gentle Visual Debounce Logic ---
|
||||||
|
if (lastSwipeTimes.has(uid)) {
|
||||||
|
const timeSinceLastSwipe = now - lastSwipeTimes.get(uid);
|
||||||
|
|
||||||
|
if (timeSinceLastSwipe < DEBOUNCE_MS) {
|
||||||
|
// Fetch the cached name and time (defaults to "User" if it bounced in the very first millisecond)
|
||||||
|
const cachedName = swipeNames.get(uid) || "User";
|
||||||
|
const cachedTime = swipeLogTimes.get(uid) || "";
|
||||||
|
|
||||||
|
// Stop clock & clear any existing timers
|
||||||
|
clearInterval(clockInterval);
|
||||||
|
clearTimeout(resetTimeout);
|
||||||
|
|
||||||
|
nameDisplay.className = 'name';
|
||||||
|
nameDisplay.innerText = cachedName;
|
||||||
|
timeDisplay.innerText = `Already logged at ${cachedTime}. Please wait 60 seconds.`;
|
||||||
|
|
||||||
|
// Revert back to clock after 4 seconds
|
||||||
|
resetTimeout = setTimeout(() => {
|
||||||
|
updateClock();
|
||||||
|
clockInterval = setInterval(updateClock, 1000);
|
||||||
|
timeDisplay.innerText = "";
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return; // Block the fetch request to the server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this swipe immediately to prevent multi-bounces while waiting for PHP
|
||||||
|
lastSwipeTimes.set(uid, now);
|
||||||
|
// -----------------------------------
|
||||||
|
|
||||||
|
const response = await fetch('log_swipe.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ uid: uid })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Cache the server's response so we can use it if they bounce again in the next 60s
|
||||||
|
swipeNames.set(uid, data.name);
|
||||||
|
swipeLogTimes.set(uid, data.time);
|
||||||
|
|
||||||
|
clearInterval(clockInterval);
|
||||||
|
clearTimeout(resetTimeout);
|
||||||
|
|
||||||
|
nameDisplay.className = 'name';
|
||||||
|
nameDisplay.innerText = data.name;
|
||||||
|
|
||||||
|
// If the PHP script caught a duplicate (e.g., the browser was refreshed wiping the JS memory)
|
||||||
|
if (data.status === 'ignored') {
|
||||||
|
timeDisplay.innerText = `Already logged at ${data.time}. Please wait 60 seconds.`;
|
||||||
|
} else {
|
||||||
|
timeDisplay.innerText = "Logged at " + data.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTimeout = setTimeout(() => {
|
||||||
|
updateClock();
|
||||||
|
clockInterval = setInterval(updateClock, 1000);
|
||||||
|
timeDisplay.innerText = "";
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "InvalidStateError") {
|
||||||
|
nameDisplay.innerText = "Already Scanning!";
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
nameDisplay.className = 'name';
|
||||||
|
nameDisplay.innerText = "Scanner Error";
|
||||||
|
timeDisplay.innerText = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<?php
|
||||||
|
// Start a secure session
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// 1. CHOOSE YOUR MANAGEMENT PASSWORD HERE:
|
||||||
|
define('MANAGEMENT_PASSWORD', '1234');
|
||||||
|
define('MANAGEMENT_TIMEOUT_SECONDS', 1200); // 20 min Inactivity threshold
|
||||||
|
|
||||||
|
// Handle Explicit Logout
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
unset($_SESSION['MANAGEMENT_PASSWORD_authenticated']);
|
||||||
|
unset($_SESSION['MANAGEMENT_last_activity']);
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Login Form Submission
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['x_secure_token'])) {
|
||||||
|
if ($_POST['x_secure_token'] === MANAGEMENT_PASSWORD) {
|
||||||
|
$_SESSION['MANAGEMENT_PASSWORD_authenticated'] = true;
|
||||||
|
$_SESSION['MANAGEMENT_last_activity'] = time();
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$login_error = "Incorrect password access denied.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-Side Inactivity Check
|
||||||
|
if (isset($_SESSION['MANAGEMENT_PASSWORD_authenticated']) && $_SESSION['MANAGEMENT_PASSWORD_authenticated'] === true) {
|
||||||
|
if (isset($_SESSION['MANAGEMENT_last_activity']) && (time() - $_SESSION['MANAGEMENT_last_activity'] > MANAGEMENT_TIMEOUT_SECONDS)) {
|
||||||
|
unset($_SESSION['MANAGEMENT_PASSWORD_authenticated']);
|
||||||
|
unset($_SESSION['MANAGEMENT_last_activity']);
|
||||||
|
header("Location: " . $_SERVER['PHP_SELF'] . "?reason=timeout");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_SESSION['MANAGEMENT_last_activity'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['reason']) && $_GET['reason'] === 'timeout') {
|
||||||
|
unset($_SESSION['MANAGEMENT_PASSWORD_authenticated']);
|
||||||
|
unset($_SESSION['MANAGEMENT_last_activity']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_SESSION['MANAGEMENT_PASSWORD_authenticated']) || $_SESSION['MANAGEMENT_PASSWORD_authenticated'] !== true) {
|
||||||
|
$display_msg = "Management Access";
|
||||||
|
if (isset($_GET['reason']) && $_GET['reason'] === 'timeout') {
|
||||||
|
$login_error = "Logged out due to 20 min of inactivity.";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Management Login</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(180deg, #1c3344, #102a33, #000000);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
margin: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 40px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { margin-bottom: 25px; color: #0d47a1; font-size: 1.8rem; font-weight: 600; }
|
||||||
|
input[type="password"] { width: 100%; padding: 15px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 8px; font-size: 1rem; }
|
||||||
|
button { width: 100%; padding: 15px; background-color: #1976d2; color: white; border: none; border-radius: 8px; font-size: 1.1rem; cursor: pointer; }
|
||||||
|
.error { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; margin-bottom: 20px; border: 1px solid #ffcdd2; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="favicon.png" alt="PiClock">
|
||||||
|
<div class="login-box">
|
||||||
|
<h2><?= htmlspecialchars($display_msg) ?></h2>
|
||||||
|
<?php if (isset($login_error)): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($login_error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST" autocomplete="off" onsubmit="requestFS()">
|
||||||
|
<input type="password" name="x_secure_token" placeholder="Enter Password" required autofocus>
|
||||||
|
<button type="submit">Secure Log In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
'PiClock' NFC Card Manager <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2025-<?php echo date('Y');?>
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Diaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Database Connection **/
|
||||||
|
$dbHost = 'db'; $dbName = 'PiClock'; $dbUser = 'root'; $dbPass = 'rootpassword';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4", $dbUser, $dbPass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
} catch (PDOException $e) { die("Database connection failed: " . $e->getMessage()); }
|
||||||
|
|
||||||
|
$message = "";
|
||||||
|
|
||||||
|
// 1. Handle Form Submission
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cards'])) {
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$stmt = $pdo->prepare("UPDATE names SET CardID = ?, Name = ? WHERE id = ?");
|
||||||
|
|
||||||
|
foreach ($_POST['cards'] as $id => $data) {
|
||||||
|
$stmt->execute([trim($data['CardID']), trim($data['Name']), $id]);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
$message = "<div style='color: #81c784; background: rgba(129, 199, 132, 0.1); border: 1px solid #81c784; padding: 15px; border-radius: 8px; font-weight: bold; margin-bottom: 20px;'>Database updated successfully!</div>";
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$message = "<div style='color: #e57373; border: 1px solid #e57373; padding: 15px; border-radius: 8px; margin-bottom: 20px;'>Error: " . $e->getMessage() . "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch data with dynamic sorting logic
|
||||||
|
$allowed_sorts = ['id', 'CardID', 'Name'];
|
||||||
|
$allowed_dirs = ['ASC', 'DESC'];
|
||||||
|
|
||||||
|
$sort_col = (isset($_GET['sort']) && in_array($_GET['sort'], $allowed_sorts)) ? $_GET['sort'] : 'id';
|
||||||
|
$sort_dir = (isset($_GET['dir']) && in_array($_GET['dir'], $allowed_dirs)) ? $_GET['dir'] : 'ASC';
|
||||||
|
|
||||||
|
// SAFELY check if the table exists before querying
|
||||||
|
$rows = []; // Initialize as empty array
|
||||||
|
try {
|
||||||
|
$check = $pdo->query("SELECT 1 FROM names LIMIT 1");
|
||||||
|
if ($check) {
|
||||||
|
$stmt = $pdo->query("SELECT id, CardID, Name FROM names ORDER BY $sort_col $sort_dir");
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Table doesn't exist, $rows remains empty, no error thrown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to build header links
|
||||||
|
function getSortUrl($column, $current_col, $current_dir) {
|
||||||
|
$next_dir = 'ASC';
|
||||||
|
if ($column === $current_col) {
|
||||||
|
$next_dir = ($current_dir === 'ASC') ? 'DESC' : 'ASC';
|
||||||
|
}
|
||||||
|
return "?sort=" . urlencode($column) . "&dir=" . urlencode($next_dir);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>PiClock NFC Card Management</title>
|
||||||
|
<link rel="icon" href="favicon.png" type="image/png">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', sans-serif; }
|
||||||
|
body { background: linear-gradient(180deg, #1c3344, #102a33, #000000); min-height: 100vh; padding: 40px 20px; color: #fff; }
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* Updated header layout */
|
||||||
|
.header-container { display: flex; justify-content: space-between; align-items: center; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.2); margin-bottom: 20px; }
|
||||||
|
.header-container h2 { margin: 0; }
|
||||||
|
|
||||||
|
/* Flex wrapper for the 3 top-right buttons */
|
||||||
|
.header-buttons { display: flex; gap: 15px; align-items: center; }
|
||||||
|
|
||||||
|
/* Base button styles */
|
||||||
|
.btn { padding: 10px 20px; border-radius: 8px; font-weight: 600; font-size: 1rem; border: none; cursor: pointer; text-decoration: none; display: inline-block; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
|
||||||
|
|
||||||
|
/* Individual button variants */
|
||||||
|
.btn-scanner { background-color: #ff9800; color: white; }
|
||||||
|
.btn-scanner:hover { background-color: #e65100; transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.btn-logout { background-color: #e53935; color: white; }
|
||||||
|
.btn-logout:hover { background-color: #c62828; transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.btn-save { background-color: #1976d2; color: white; width: 100%; padding: 16px; font-size: 1.2rem; border: none; margin-top: 10px; }
|
||||||
|
.btn-save:hover { background-color: #1565c0; transform: translateY(-2px); box-shadow: 0 6px 20px rgba(25, 118, 210, 0.4); }
|
||||||
|
|
||||||
|
/* Updated Card Table Container to cleanly crop the inner table corners */
|
||||||
|
.card-table { background: #fff; border-radius: 12px; color: #333; overflow: hidden; }
|
||||||
|
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th { background-color: #0d47a1; color: white; padding: 16px; text-align: left; }
|
||||||
|
td { padding: 12px 16px; border-bottom: 1px solid #e0e0e0; }
|
||||||
|
|
||||||
|
/* Remove the border from the very last row so the bottom curves are smooth */
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Table Header Sort Links */
|
||||||
|
th a { color: #ffffff; text-decoration: none; display: block; width: 100%; height: 100%; cursor: pointer; }
|
||||||
|
th a:hover, th a:visited, th a:active { color: #ffffff; text-decoration: none; }
|
||||||
|
|
||||||
|
input[type="text"] { width: 100%; padding: 12px; border: 1px solid #ccc; border-radius: 8px; }
|
||||||
|
.barcode { display: block; margin: auto; height: 35px; }
|
||||||
|
.instructions { background: rgba(255, 255, 255, 0.1); padding: 20px 25px; border-radius: 12px; margin-bottom: 30px; border-left: 5px solid #ffb300; backdrop-filter: blur(5px); }
|
||||||
|
.instructions p { margin-bottom: 8px; font-size: 1.05rem; line-height: 1.5; opacity: 0.9; }
|
||||||
|
.instructions p:last-child { margin-bottom: 0; }
|
||||||
|
.warning-text { color: #ffcc80; font-weight: bold; letter-spacing: 0.5px; }
|
||||||
|
.footer { text-align: center; margin-top: 40px; color: rgba(255, 255, 255, 0.7); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-container">
|
||||||
|
<h2>Assign Card Names</h2>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<a href="/dev.php" target="_blank" class="btn btn-logout">⚠️ DB Dev ⚠️</a>
|
||||||
|
<a href="https://habipi5.swordfish-goanna.ts.net/scanner.html" target="_blank" class="btn btn-scanner">Scanner</a>
|
||||||
|
<a href="?logout=1" class="btn btn-logout">Log Out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<p>To update the employee names use the fields below to rename each NFC Card.
|
||||||
|
<br>Then scroll to the bottom and click "Save Changes".</p>
|
||||||
|
<p class="warning-text">⚠️ WARNING: "Save Changes" writes ALL VALUES to the database!</p>
|
||||||
|
<p style="font-size: 0.85rem; margin-top: 10px; opacity: 0.7;">🔒 For security, this session will automatically time out after 20 minutes of inactivity.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $message ?>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="card-table">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 10%;"><a href="<?= getSortUrl('id', $sort_col, $sort_dir) ?>">ID</a></th>
|
||||||
|
<th style="width: 20%;"><a href="<?= getSortUrl('CardID', $sort_col, $sort_dir) ?>">Card ID</a></th>
|
||||||
|
<th style="width: 40%;"><a href="<?= getSortUrl('Name', $sort_col, $sort_dir) ?>">Employee Name</a></th>
|
||||||
|
<th style="text-align: center; width: 30%;">Barcode</th>
|
||||||
|
</tr>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<tr>
|
||||||
|
<td><code><?= htmlspecialchars($row['id']) ?></code></td>
|
||||||
|
<td><code><?= htmlspecialchars($row['CardID']) ?></code></td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="cards[<?= $row['id'] ?>][CardID]" value="<?= htmlspecialchars($row['CardID']) ?>" style="display:none;">
|
||||||
|
<input type="text" name="cards[<?= $row['id'] ?>][Name]" value="<?= htmlspecialchars($row['Name']) ?>">
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center;"><img class="barcode" data-code="<?= htmlspecialchars($row['CardID']) ?>"></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-save">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
<div class="footer">
|
||||||
|
'PiClock' NFC Card Manager <span style="display: inline-block; transform: rotateY(180deg);">©</span> 2025-<?php echo date('Y');?>
|
||||||
|
<br>made with ♥ by F.Javier "<a href="mailto:habiwan@me.com" style="color: #fff;">habiwan</a>" Puig Diaz
|
||||||
|
<br><img src="habiwan.png" alt="habiwan">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="JsBarcode.all.min.js" defer></script>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
document.querySelectorAll('.barcode').forEach(function(element) {
|
||||||
|
const code = element.getAttribute('data-code');
|
||||||
|
try { JsBarcode(element, code, {format: "CODE128", width: 1.5, height: 35, displayValue: false, margin: 5}); }
|
||||||
|
catch (e) { console.error("Barcode failed", e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Pass the PHP timeout value into JavaScript to log out after inactivity
|
||||||
|
const TIMEOUT_DURATION = <?php echo MANAGEMENT_TIMEOUT_SECONDS; ?> * 1000;
|
||||||
|
let logoutTimer;
|
||||||
|
|
||||||
|
function resetTimer() {
|
||||||
|
clearTimeout(logoutTimer);
|
||||||
|
logoutTimer = setTimeout(logoutUser, TIMEOUT_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutUser() {
|
||||||
|
window.location.href = "?logout=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
window.onload = resetTimer;
|
||||||
|
window.onmousemove = resetTimer;
|
||||||
|
window.onmousedown = resetTimer;
|
||||||
|
window.ontouchstart = resetTimer;
|
||||||
|
window.onclick = resetTimer;
|
||||||
|
window.onkeydown = resetTimer;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+3594
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user