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