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:
jpd committed 2026-07-24 14:37:45 +01:00
1 parent 10824ab851
commit a201d5d7ac
34 files changed
+9453

No files matched your search

+821
View File
@@ -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>
+2462
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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);">&copy;</span> 2026
<br>made with &hearts; 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);">&copy;</span> 2026
<br>made with &hearts; 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>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+127
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

+534
View File
@@ -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);">&copy;</span> 2025-<?php echo date('Y');?>
<br>made with &hearts; 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);">&copy;</span> 2025-<?php echo date('Y');?>
<br>made with &hearts; 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>
+86
View File
@@ -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);
}
}
?>
+40
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "NFC Scanner",
"short_name": "Scanner",
"start_url": "scanner.html",
"display": "standalone",
"background_color": "#1e3c72",
"theme_color": "#1e3c72",
"icons": []
}
+102
View File
@@ -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
1 CardID EmployeeName
2 n/a LOGOUT
3 4b6f6baa3f90 Maximilian Q.
4 4fa7baa3f90 Angelo P.
5 4afebaa3f91 Javier S.
6 474fcbaa3f90 Peter M.
7 46f2baa3f94 John S.
8 420a3baa3f90 Manolo F.
9 458d8baa3f90 Francis X.
10 4fce5baa3f90 Xavier F.
11 4a1e2baa3f90 Martha D.
12 4d3adbaa3f90 Edu A.
13 482d8baa3f90 Jesus P.
14 42cf0baa3f91 David M.
15 49125baa3f90 Cristina S.
16 4fbedbaa3f90 Steven H.
17 425edbaa3f91 Joshua K.
18 4e90baa3f94 Jacky C.
19 4eaeabaa3f90 Daniel F.
20 45fc0baa3f91 Alexander T.
21 448efbaa3f91 Dominikas Z.
22 442f4baa3f91 Andreas M.
23 4f79bbaa3f90 Gustav G.
24 482c6baa3f91 _INACTIVE21
25 46cfabaa3f90 _INACTIVE22
26 4ccabaa3f91 _INACTIVE23
27 427eabaa3f91 _INACTIVE24
28 4109bbaa3f91 _INACTIVE25
29 47bcdbaa3f90 _INACTIVE26
30 44abfbaa3f90 _INACTIVE27
31 477d4baa3f90 _INACTIVE28
32 4bbf6baa3f90 _INACTIVE29
33 42b0baa3f91 _INACTIVE30
34 4caf6baa3f90 _INACTIVE31
35 4b6c9baa3f90 Danielle F.
36 41defbaa3f91 Luke S.
37 4ef55baa3f90 _INACTIVE34
38 49bcdbaa3f90 _INACTIVE35
39 471b5baa3f91 _INACTIVE36
40 464f7baa3f90 _INACTIVE37
41 487d4baa3f90 _INACTIVE38
42 423f7baa3f91 _INACTIVE39
43 4839abaa3f91 _INACTIVE40
44 4c0d5baa3f90 _INACTIVE41
45 4629bbaa3f91 _INACTIVE42
46 49cf6baa3f90 _INACTIVE43
47 446a3baa3f90 _INACTIVE44
48 4bed7baa3f90 _INACTIVE45
49 4e0d4baa3f90 _INACTIVE46
50 43fcbbaa3f91 _INACTIVE47
51 45dd9baa3f91 _INACTIVE48
52 481d8baa3f90 _INACTIVE49
53 434c6baa3f91 _INACTIVE50
54 4b76baa3f94 _INACTIVE51
55 41517baa3f91 _INACTIVE52
56 4b7f5baa3f90 _INACTIVE53
57 4b5d0baa3f90 Luis H.
58 457baa3f95 _INACTIVE55
59 4eb1baa3f94 _INACTIVE56
60 4e7c8baa3f90 _INACTIVE57
61 487c2baa3f90 Francisco J.
62 410f4baa3f91 _INACTIVE59
63 46eacbaa3f91 _INACTIVE60
64 4ceeabaa3f90 _INACTIVE61
65 401baa3f95 _INACTIVE62
66 4dad6baa3f90 _INACTIVE63
67 4cecbaa3f94 _INACTIVE64
68 41ba7baa3f90 _INACTIVE65
69 43a9cbaa3f91 _INACTIVE66
70 48c6baa3f94 _INACTIVE67
71 4841cbaa3f90 _INACTIVE68
72 467fdbaa3f90 _INACTIVE69
73 4865baa3f94 _INACTIVE70
74 44ed8baa3f90 _INACTIVE71
75 4e9efbaa3f90 _INACTIVE72
76 4a0d7baa3f90 _INACTIVE73
77 45a9bbaa3f91 _INACTIVE75
78 458d1baa3f91 _INACTIVE76
79 4eff3baa3f90 _INACTIVE77
80 47ffdbaa3f90 _INACTIVE78
81 4c6adbaa3f90 _INACTIVE79
82 4e30baa3f91 Mikhail G.
83 446a7baa3f90 Donald T.
84 47a9fbaa3f90 _INACTIVE82
85 426edbaa3f91 _INACTIVE83
86 42abfbaa3f90 _INACTIVE84
87 483fcbaa3f90 _INACTIVE85
88 4d9efbaa3f90 _INACTIVE86
89 4a1c0baa3f90 _INACTIVE87
90 44ff8baa3f90 _INACTIVE88
91 458f7baa3f90 _INACTIVE89
92 4c1d7baa3f90 _INACTIVE90
93 4c6f7baa3f90 _INACTIVE91
94 484aabaa3f90 _INACTIVE92
95 4139cbaa3f91 _INACTIVE93
96 482adbaa3f90 _INACTIVE94
97 490acbaa3f90 _INACTIVE95
98 46f3baa3f91 _INACTIVE96
99 465fdbaa3f90 _INACTIVE97
100 4a68baa3f94 _INACTIVE98
101 458d8baa3f91 _INACTIVE99
102 xxxxxxxxxxxx _NOTFOUND
+174
View File
@@ -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>
+31
View File
@@ -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
1 Hostname IP MAC Type
2 vodafone UltraHub 7 192.168.183.1 18:EF:C0:12:84:E0 Infrastructure
3 mandalorian (Synology) 192.168.183.3 00:11:32:97:21:51 Service
4 Zyxel Switch 192.168.183.9 98:0D:67:08:89:0E Infrastructure
5 habiNote10 192.168.183.51 8C:B8:4A:20:04:5C Client
6 habiSufaceDuo2 192.168.183.55 A0:4A:5E:CF:98:B2 Client
7 habiPi5 192.168.183.100 D8:3A:DD:F4:C5:57 Service
8 habiROG 192.168.183.101 A8:5E:45:BF:31:4C Client
9 habiPlex (minimac) 192.168.183.102 5C:1B:F4:7C:55:FD Service
10 habiDell (Commodore) 192.168.183.104 E4:F8:9C:8B:8B:6B Client
11 habiClevo 192.168.183.106 80:FA:5B:26:7F:EF Client
12 habiMiniPC (TV) 192.168.183.110 DE:96:5D:C7:9E:1E Service
13 DCP-L2530DWE67C 192.168.183.116 8C:C8:4B:1D:E6:7C Infrastructure
14 habiPhoneSE 192.168.183.128 E0:5F:45:51:A7:E8 Client
15 Nintendo Switch 192.168.183.130 40:44:F7:D0:74:2D Client
16 RP761B_0538 WiFi Booster 192.168.183.138 94:F7:BE:E9:35:C0 Infrastructure
17 Amazon 192.168.183.142 94:3A:91:C5:5E:82 Client
18 habiMac 192.168.183.144 DE:3D:DC:CD:20:3B Client
19 Sony PlayStation 192.168.183.152 B4:0A:D8:79:71:B2 Client
20 Proxmox gateway 192.168.183.163 BC:24:11:AA:AB:98 Transient
21 l337 Raspberry Pi 192.168.183.170 DC:A6:32:0F:8F:6A Client
22 habiTv 192.168.183.171 A8:51:AB:A7:85:37 Client
23 iPhone 192.168.183.187 E2:F4:11:DF:4A:C0 Client
24 palpatine Apple TV 192.168.183.192 60:65:25:E9:F3:AE Client
25 StarDestroyer hp PVE 192.168.183.200 9C:8E:99:5B:D1:04 Client
26 Unknown 192.168.183.230 02:CB:89:57:0C:B4 Client
27 Unknown 192.168.183.231 02:08:72:A7:A2:7A Client
28 Unknown 192.168.183.232 02:A4:51:AA:D9:57 Client
29 Unknown 192.168.183.233 02:80:35:82:FD:82 Client
30 Unknown 192.168.183.235 02:4B:C7:90:45:90 Client
31 HP Integrated Lights-Out 192.168.183.252 9C:8E:99:5B:D1:08 Service
+24
View File
@@ -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.
+249
View File
@@ -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);">&copy;</span> 2026
<br>made with &hearts; 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>
+306
View File
@@ -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);">&copy;</span> 2025-<?php echo date('Y');?>
<br>made with &hearts; 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);">&copy;</span> 2025-<?php echo date('Y');?>
<br>made with &hearts; 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
View File
File diff suppressed because it is too large. Load diff