init commit
This commit is contained in:
commit
2c7c36478f
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# These are some examples of commonly ignored file patterns.
|
||||
# You should customize this list as applicable to your project.
|
||||
# Learn more about .gitignore:
|
||||
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
|
||||
|
||||
# Node artifact files
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Compiled Java class files
|
||||
*.class
|
||||
|
||||
# Compiled Python bytecode
|
||||
*.py[cod]
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Package files
|
||||
*.jar
|
||||
|
||||
# Maven
|
||||
target/
|
||||
dist/
|
||||
|
||||
# JetBrains IDE
|
||||
.idea/
|
||||
|
||||
# Unit test reports
|
||||
TEST*.xml
|
||||
|
||||
# Generated by MacOS
|
||||
.DS_Store
|
||||
|
||||
# Generated by Windows
|
||||
Thumbs.db
|
||||
|
||||
# Applications
|
||||
*.app
|
||||
*.exe
|
||||
*.war
|
||||
|
||||
# Large media files
|
||||
*.mp4
|
||||
*.tiff
|
||||
*.avi
|
||||
*.flv
|
||||
*.mov
|
||||
*.wmv
|
||||
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
<?php
|
||||
// Validate token strictly before rendering anything.
|
||||
$token = isset($_GET['token']) ? trim($_GET['token']) : '';
|
||||
|
||||
function renderError($message) {
|
||||
header("Location: 404.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($token)) {
|
||||
renderError("The link you are using missing an access token.");
|
||||
}
|
||||
|
||||
$tokensFile = __DIR__ . '/door_tokens.json';
|
||||
if (!file_exists($tokensFile)) {
|
||||
renderError("The validation system is currently unavailable.");
|
||||
}
|
||||
$tokens = json_decode(file_get_contents($tokensFile), true) ?: [];
|
||||
|
||||
if (!isset($tokens[$token])) {
|
||||
renderError("Access token not found or invalid.");
|
||||
}
|
||||
if ($tokens[$token]['used']) {
|
||||
renderError("This link has already been used. Please request a new link from the Admin.");
|
||||
}
|
||||
if (time() - $tokens[$token]['created_at'] > 300) { // 5 minutes expiration
|
||||
renderError("This access link has expired (more than 5 minutes).");
|
||||
}
|
||||
|
||||
// Mark as used exclusively for this session
|
||||
$tokens[$token]['used'] = true;
|
||||
file_put_contents($tokensFile, json_encode($tokens, JSON_PRETTY_PRINT));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
|
||||
<title>Face Access — Identia</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="vendors/mdi/css/materialdesignicons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4747a1;
|
||||
--primary-glow: rgba(71,71,161,0.3);
|
||||
--success: #10b981;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px 16px 40px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 20px;
|
||||
width: 100%; max-width: 460px;
|
||||
padding: 32px 28px 36px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── HEADER ── */
|
||||
.card-header {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header-icon {
|
||||
width: 52px; height: 52px; border-radius: 16px;
|
||||
background: #e6e6f2; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.header-icon i { font-size: 26px; color: var(--primary); }
|
||||
.header-text h1 { font-size: 20px; font-weight: 800; color: var(--text); letter-spacing: -0.3px; }
|
||||
.header-text p { font-size: 12px; color: var(--muted); margin-top: 3px; font-weight: 500; }
|
||||
|
||||
/* ── TIMER STATUS ── */
|
||||
.badge-timer {
|
||||
position: absolute; top: 20px; right: 28px;
|
||||
background: #fef2f2; border: 1px solid #fecaca;
|
||||
color: #ef4444; font-size: 12px; font-weight: 700;
|
||||
padding: 6px 12px; border-radius: 20px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
|
||||
/* ── CAMERA WRAPPER ── */
|
||||
.cam-wrapper {
|
||||
width: 100%; position: relative;
|
||||
border-radius: 16px; overflow: hidden;
|
||||
background: #1a1e20; aspect-ratio: 3/4; cursor: default;
|
||||
border: 1px solid var(--border); transition: all 0.4s ease;
|
||||
}
|
||||
#cam-video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); display: block; }
|
||||
|
||||
#scan-line {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 3px; background: #ffffff;
|
||||
box-shadow: 0 0 10px #ffffff, 0 0 20px rgba(255,255,255,0.5);
|
||||
z-index: 4; animation: scanMove 2.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scanMove { 0% { top: 0%; } 50% { top: calc(100% - 3px); } 100% { top: 0%; } }
|
||||
|
||||
#cam-loading {
|
||||
position: absolute; inset: 0; background: #ffffff;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 16px; z-index: 10;
|
||||
}
|
||||
.ld-ring {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
border: 3px solid #e6e6f2; border-top-color: var(--primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#cam-loading p { font-size: 13px; color: var(--muted); font-weight: 600; text-align: center; padding: 0 20px;}
|
||||
|
||||
/* Live status pill */
|
||||
#face-pill {
|
||||
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
|
||||
display: none; align-items: center; gap: 8px;
|
||||
padding: 8px 18px; border-radius: 24px;
|
||||
font-size: 12px; font-weight: 700; white-space: nowrap; z-index: 5;
|
||||
background: rgba(255,255,255,0.9); color: var(--text); backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* ── SUCCESS VIEW (Replaces Camera) ── */
|
||||
.success-view {
|
||||
display: none; flex-direction: column; align-items: center;
|
||||
justify-content: center; text-align: center; gap: 16px;
|
||||
padding: 40px 20px; background: #f0fdf4; border-radius: 16px;
|
||||
border: 1px solid #bbf7d0; margin-bottom: 20px; animation: popIn 0.5s ease;
|
||||
}
|
||||
@keyframes popIn { 0%{opacity:0; transform:scale(0.9);} 100%{opacity:1; transform:scale(1);} }
|
||||
.success-icon {
|
||||
width: 80px; height: 80px; border-radius: 50%; background: #dcfce7;
|
||||
display: flex; align-items: center; justify-content: center; color: #16a34a; font-size: 40px;
|
||||
}
|
||||
.success-view h2 { font-size: 22px; font-weight: 800; color: #166534; margin:0; }
|
||||
.success-view p { font-size: 14px; color: #15803d; margin:0; font-weight: 500;}
|
||||
|
||||
/* ── UNLOCK BUTTON ── */
|
||||
.btn-unlock {
|
||||
display: none; width: 100%; padding: 18px;
|
||||
background: var(--primary); color: #fff;
|
||||
border: none; border-radius: 16px; font-size: 16px; font-weight: 800;
|
||||
cursor: pointer; display: none; align-items: center; justify-content: center; gap: 10px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 10px 20px rgba(71, 71, 161, 0.4);
|
||||
margin-top: 10px; letter-spacing: 0.5px;
|
||||
}
|
||||
.btn-unlock:hover { transform: translateY(-3px); box-shadow: 0 15px 30px rgba(71, 71, 161, 0.5); }
|
||||
.btn-unlock:active { transform: translateY(0); }
|
||||
.btn-unlock.opened { background: var(--success); box-shadow: 0 10px 20px rgba(16, 185, 129, 0.4); pointer-events: none;}
|
||||
|
||||
.toast {
|
||||
position: fixed; top: 20px; right: 20px; background: #fff; border-left: 4px solid var(--success);
|
||||
padding: 14px 20px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
display: none; z-index: 10000; font-weight: 600; font-size: 14px;
|
||||
}
|
||||
|
||||
.overlay-mask {
|
||||
display: none; position: fixed; inset: 0; background: rgba(255,255,255,0.9);
|
||||
z-index: 9999; flex-direction: column; align-items: center; justify-content: center; text-align: center;
|
||||
}
|
||||
.overlay-mask h1 { color: #0f172a; font-size: 24px; font-weight: 800; }
|
||||
.overlay-mask p { color: #64748b; font-size: 16px; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="card">
|
||||
<div class="badge-timer" id="timerBadge"><i class="mdi mdi-timer-sand"></i> <span id="timeTxt">05:00</span></div>
|
||||
|
||||
<div class="card-header">
|
||||
<div class="header-icon"><i class="mdi mdi-face-recognition"></i></div>
|
||||
<div class="header-text">
|
||||
<h1>Face Verification</h1>
|
||||
<p>Scan your face to unlock the door</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera View Focus -->
|
||||
<div class="cam-wrapper" id="camWrapper">
|
||||
<div id="cam-loading">
|
||||
<div class="ld-ring"></div>
|
||||
<p id="camLoadTxt">Connecting to API and loading AI face models...</p>
|
||||
</div>
|
||||
<div id="scan-line"></div>
|
||||
<video id="cam-video" autoplay playsinline muted></video>
|
||||
<div id="face-pill"><i class="mdi mdi-magnify" id="fpIcon"></i> <span id="fpTxt">Searching for Face...</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Success Output -->
|
||||
<div class="success-view" id="successView">
|
||||
<div class="success-icon"><i class="mdi mdi-check-decagram"></i></div>
|
||||
<h2>Access Granted!</h2>
|
||||
<p>Hello, <strong id="ocName">John Doe</strong>.</p>
|
||||
</div>
|
||||
|
||||
<!-- The Door Mechanism -->
|
||||
<button class="btn-unlock" id="btnUnlock">
|
||||
<i class="mdi mdi-door-closed" id="doorIcon"></i> <span id="btnTxt">Open the door</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-unlock" id="btnLock" style="display:none; background:#ef4444; box-shadow:0 10px 20px rgba(239,68,68,0.3); margin-top:12px;">
|
||||
<i class="mdi mdi-lock" id="lockIcon"></i> <span id="lockTxt">Lock the door</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toastMsg">Status updated!</div>
|
||||
|
||||
<div class="overlay-mask" id="expireMask">
|
||||
<i class="mdi mdi-timer-off-outline" style="font-size: 60px; color:#f59e0b; margin-bottom:10px;"></i>
|
||||
<h1>Session Expired</h1>
|
||||
<p>Your session has expired. This page will close automatically.</p>
|
||||
</div>
|
||||
|
||||
<!-- Mengambil faceapi dari vendor as referenced from registrasi_foto.php -->
|
||||
<script src="js/face-api/face-api.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// UI Elements
|
||||
const camWrapper = document.getElementById('camWrapper');
|
||||
const camLoadTxt = document.getElementById('camLoadTxt');
|
||||
const camLoading = document.getElementById('cam-loading');
|
||||
const video = document.getElementById('cam-video');
|
||||
const facePill = document.getElementById('face-pill');
|
||||
const fpIcon = document.getElementById('fpIcon');
|
||||
const fpTxt = document.getElementById('fpTxt');
|
||||
const btnUnlock = document.getElementById('btnUnlock');
|
||||
const successView = document.getElementById('successView');
|
||||
const ocName = document.getElementById('ocName');
|
||||
|
||||
let faceMatcher = null;
|
||||
let recognitionLoop = null;
|
||||
let stream = null;
|
||||
|
||||
// Page expires at max 5 mins tightly
|
||||
let secondsRemaining = 300;
|
||||
let doorTimer = null;
|
||||
|
||||
const TOKEN = new URLSearchParams(window.location.search).get('token');
|
||||
|
||||
// Display timer
|
||||
const timeInterval = setInterval(() => {
|
||||
secondsRemaining--;
|
||||
if(secondsRemaining <= 0) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
const m = Math.floor(secondsRemaining / 60);
|
||||
const s = secondsRemaining % 60;
|
||||
document.getElementById('timeTxt').textContent = `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
||||
}, 1000);
|
||||
|
||||
function expireSession() {
|
||||
clearInterval(timeInterval);
|
||||
clearTimeout(doorTimer);
|
||||
document.getElementById('expireMask').style.display = 'flex';
|
||||
// Cleanup tokens automatically handled by API generating, but we enforce it here locally.
|
||||
setTimeout(() => { window.location.href = '192.168.4.1.php'; }, 4000);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
// Load neural nets. We fallback to CDN if faceRecognitionNet isn't local.
|
||||
camLoadTxt.textContent = "Loading Face APIs...";
|
||||
|
||||
await faceapi.nets.ssdMobilenetv1.loadFromUri('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model');
|
||||
await faceapi.nets.faceLandmark68Net.loadFromUri('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model');
|
||||
await faceapi.nets.faceRecognitionNet.loadFromUri('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model');
|
||||
|
||||
// Get reference faces
|
||||
camLoadTxt.textContent = "Fetcfhing Access Data...";
|
||||
const res = await fetch('api_get_reference_faces.php');
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
camLoadTxt.textContent = "No registered users to match.";
|
||||
return;
|
||||
}
|
||||
|
||||
const labeledDescriptors = [];
|
||||
let totalProcessed = 0;
|
||||
|
||||
for (const occ of data.data) {
|
||||
camLoadTxt.textContent = `Processing reference... ${occ.name}`;
|
||||
try {
|
||||
const img = await faceapi.fetchImage(occ.foto + '?t=' + Date.now());
|
||||
const detect = await faceapi.detectSingleFace(img).withFaceLandmarks().withFaceDescriptor();
|
||||
if (detect) {
|
||||
labeledDescriptors.push(new faceapi.LabeledFaceDescriptors(occ.name, [detect.descriptor]));
|
||||
totalProcessed++;
|
||||
}
|
||||
} catch(e) { console.error("Could not process " + occ.name); }
|
||||
}
|
||||
|
||||
if (totalProcessed === 0) {
|
||||
camLoadTxt.textContent = "All photo references failed to load. Please call a technician.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Matcher (Distance 0.55 makes sure it's accurate)
|
||||
faceMatcher = new faceapi.FaceMatcher(labeledDescriptors, 0.55);
|
||||
|
||||
// Start Webcam
|
||||
camLoadTxt.textContent = "Starting Webcam...";
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
||||
video.srcObject = stream;
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
camLoading.style.display = 'none';
|
||||
facePill.style.display = 'flex';
|
||||
startMatching();
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
camLoadTxt.textContent = "Error: " + err.message;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function startMatching() {
|
||||
recognitionLoop = setInterval(async () => {
|
||||
if (video.paused) return;
|
||||
|
||||
const detection = await faceapi.detectSingleFace(video).withFaceLandmarks().withFaceDescriptor();
|
||||
|
||||
if (detection) {
|
||||
const bestMatch = faceMatcher.findBestMatch(detection.descriptor);
|
||||
|
||||
if (bestMatch.label !== 'unknown') {
|
||||
// Berhasil Deteksi!
|
||||
fpIcon.className = "mdi mdi-check-circle";
|
||||
fpTxt.textContent = "Face Recognized!";
|
||||
facePill.style.background = "#10b981";
|
||||
facePill.style.color = "#fff";
|
||||
|
||||
clearInterval(recognitionLoop);
|
||||
handleSuccess(bestMatch.label);
|
||||
} else {
|
||||
fpIcon.className = "mdi mdi-alert";
|
||||
fpTxt.textContent = "Face Not Recognized";
|
||||
facePill.style.background = "#ef4444";
|
||||
facePill.style.color = "#fff";
|
||||
}
|
||||
} else {
|
||||
fpIcon.className = "mdi mdi-magnify";
|
||||
fpTxt.textContent = "Searching for Face...";
|
||||
facePill.style.background = "rgba(255,255,255,0.9)";
|
||||
facePill.style.color = "#0f172a";
|
||||
}
|
||||
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleSuccess(name) {
|
||||
// Stop Camera Streams
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
|
||||
// Hide camera, show success logic
|
||||
camWrapper.style.display = 'none';
|
||||
ocName.textContent = name;
|
||||
successView.style.display = 'flex';
|
||||
|
||||
// Show btn
|
||||
btnUnlock.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Door Interactivity
|
||||
btnUnlock.addEventListener('click', async () => {
|
||||
// Play action unlock
|
||||
btnUnlock.querySelector('#btnTxt').textContent = "Opening...";
|
||||
btnUnlock.querySelector('#doorIcon').className = "mdi mdi-loading mdi-spin";
|
||||
|
||||
try {
|
||||
// Post Unlock
|
||||
let formData = new URLSearchParams();
|
||||
formData.append("action", "unlock");
|
||||
|
||||
const res = await fetch("api_door_status.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString()
|
||||
});
|
||||
const dat = await res.json();
|
||||
|
||||
if(dat.status === 'success') {
|
||||
btnUnlock.classList.add('opened');
|
||||
btnUnlock.querySelector('#doorIcon').className = "mdi mdi-door-open";
|
||||
btnUnlock.querySelector('#btnTxt').textContent = "Door Unlocked!";
|
||||
btnUnlock.style.pointerEvents = 'none';
|
||||
|
||||
// Show the Lock button
|
||||
document.getElementById('btnLock').style.display = 'flex';
|
||||
|
||||
showToast("Door unlocked successfully! Access expires in 5 minutes.");
|
||||
|
||||
// Clear the earlier page countdown, force a new 5 minute door lock countdown
|
||||
clearInterval(timeInterval);
|
||||
document.getElementById('timerBadge').innerHTML = "<i class='mdi mdi-lock-clock'></i> Will automatically lock in 5 mins";
|
||||
|
||||
// Lock back after 5 minutes
|
||||
doorTimer = setTimeout(() => {
|
||||
lockTheDoor();
|
||||
}, 300000); // 300 seconds
|
||||
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Failed handling door API", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Lock The Door button
|
||||
document.getElementById('btnLock').addEventListener('click', async () => {
|
||||
clearTimeout(doorTimer); // Cancel auto-lock timer
|
||||
await lockTheDoor();
|
||||
});
|
||||
|
||||
async function lockTheDoor() {
|
||||
const lockBtn = document.getElementById('btnLock');
|
||||
lockBtn.querySelector('#lockTxt').textContent = 'Locking...';
|
||||
lockBtn.querySelector('#lockIcon').className = 'mdi mdi-loading mdi-spin';
|
||||
lockBtn.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
// 1. Lock the door
|
||||
let formLock = new URLSearchParams();
|
||||
formLock.append("action", "lock");
|
||||
await fetch("api_door_status.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formLock.toString()
|
||||
});
|
||||
|
||||
// 2. Turn off Bluetooth on ESP32
|
||||
await fetch('api_wifi.php?action=trigger_bt_off');
|
||||
|
||||
showToast('Door locked and Bluetooth disabled.');
|
||||
} catch(e) {
|
||||
console.error('Lock error:', e);
|
||||
}
|
||||
|
||||
// End session regardless
|
||||
expireSession();
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toastMsg');
|
||||
t.textContent = msg;
|
||||
t.style.display = 'block';
|
||||
setTimeout(() => { t.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
|
||||
<title>Identia Connect Portal</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="vendors/mdi/css/materialdesignicons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4747a1;
|
||||
--primary-light: #e6e6f2;
|
||||
--primary-glow: rgba(71,71,161,0.3);
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(135deg, #f0f4f8, var(--bg));
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
background: var(--card);
|
||||
border-radius: 24px;
|
||||
width: 100%; max-width: 400px;
|
||||
padding: 40px 30px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
width: 90px; height: 90px; border-radius: 50%;
|
||||
background: var(--primary-light);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
position: relative;
|
||||
}
|
||||
.icon-wrap.pulsing::before {
|
||||
content: ''; position: absolute; inset: -10px; border-radius: 50%;
|
||||
border: 2px solid var(--primary-glow);
|
||||
animation: pulse-ring 2s infinite;
|
||||
}
|
||||
.icon-wrap.active { background: #dcfce7; }
|
||||
.icon-wrap.active::before { border-color: #86efac; }
|
||||
.icon-wrap.error { background: #fef2f2; }
|
||||
.icon-wrap.error::before { border-color: #fecaca; }
|
||||
|
||||
@keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }
|
||||
.icon-wrap i { font-size: 44px; color: var(--primary); transition: color 0.3s; }
|
||||
.icon-wrap.active i { color: #10b981; }
|
||||
.icon-wrap.error i { color: #ef4444; }
|
||||
|
||||
.portal-card h1 { font-size: 24px; font-weight: 800; color: var(--text); margin-bottom: 8px; letter-spacing: -0.5px; }
|
||||
.portal-card p.desc { font-size: 14px; color: var(--muted); margin-bottom: 32px; line-height: 1.6; }
|
||||
|
||||
.btn-connect {
|
||||
background: var(--primary); color: #fff;
|
||||
border: none; border-radius: 16px;
|
||||
width: 100%; padding: 16px; font-size: 15px; font-weight: 700;
|
||||
cursor: pointer; font-family: 'Inter', sans-serif;
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 10px 20px var(--primary-glow);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.btn-connect:hover { transform: translateY(-3px); box-shadow: 0 15px 25px var(--primary-glow); }
|
||||
.btn-connect:active { transform: translateY(0); box-shadow: 0 5px 10px var(--primary-glow); }
|
||||
.btn-connect:disabled { opacity: 0.7; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.btn-connect .spinner { display: none; font-size: 18px; animation: spin 1s linear infinite; }
|
||||
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Step Progress Bar ── */
|
||||
.steps {
|
||||
display: flex; gap: 4px; margin-bottom: 24px;
|
||||
}
|
||||
.steps .step {
|
||||
flex: 1; height: 4px; border-radius: 2px;
|
||||
background: var(--border); transition: background 0.5s;
|
||||
}
|
||||
.steps .step.done { background: #10b981; }
|
||||
.steps .step.active { background: var(--primary); animation: stepPulse 1.2s infinite; }
|
||||
@keyframes stepPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.status-msg { margin-top: 20px; font-size: 13px; font-weight: 600; min-height: 40px; color: var(--muted); line-height: 1.5; }
|
||||
.status-msg.error { color: #ef4444; }
|
||||
.status-msg.success { color: #10b981; }
|
||||
|
||||
/* Security badge */
|
||||
.sec-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-top: 30px; padding: 8px 16px; border-radius: 20px;
|
||||
background: #f1f5f9; font-size: 11px; font-weight: 600; color: #64748b;
|
||||
}
|
||||
.sec-badge i { color: #10b981; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="portal-card">
|
||||
<!-- Step progress indicator -->
|
||||
<div class="steps" id="steps">
|
||||
<div class="step" id="step1"></div>
|
||||
<div class="step" id="step2"></div>
|
||||
<div class="step" id="step3"></div>
|
||||
<div class="step" id="step4"></div>
|
||||
</div>
|
||||
|
||||
<div class="icon-wrap pulsing" id="iconWrap">
|
||||
<i class="mdi mdi-bluetooth" id="btIcon"></i>
|
||||
</div>
|
||||
|
||||
<h1>Identia Bluetooth</h1>
|
||||
<p class="desc">Connect this device to the Bluetooth door module to proceed with the verification process.</p>
|
||||
|
||||
<button class="btn-connect" id="btnConnect">
|
||||
<i class="mdi mdi-loading spinner" id="spinIcon"></i>
|
||||
<i class="mdi mdi-bluetooth-connect" id="btnIcon"></i>
|
||||
<span id="btnText">Connect Bluetooth</span>
|
||||
</button>
|
||||
|
||||
<div class="status-msg" id="statusMsg"></div>
|
||||
|
||||
<div class="sec-badge">
|
||||
<i class="mdi mdi-shield-check"></i> End-to-End Encryption
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('btnConnect').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('btnConnect');
|
||||
const btnText = document.getElementById('btnText');
|
||||
const spinIcon = document.getElementById('spinIcon');
|
||||
const btnIcon = document.getElementById('btnIcon');
|
||||
const statusMsg = document.getElementById('statusMsg');
|
||||
const iconWrap = document.getElementById('iconWrap');
|
||||
const btIcon = document.getElementById('btIcon');
|
||||
|
||||
// Reset status
|
||||
statusMsg.className = 'status-msg';
|
||||
statusMsg.textContent = '';
|
||||
|
||||
try {
|
||||
if (!navigator.bluetooth) {
|
||||
throw new Error("Web Bluetooth is not supported in this browser. Please use Chrome and enable the feature if necessary.");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// STEP 1: Wake up ESP32 Bluetooth
|
||||
// ══════════════════════════════════════════
|
||||
btn.disabled = true;
|
||||
btnIcon.style.display = 'none';
|
||||
spinIcon.style.display = 'inline-block';
|
||||
setStep(1);
|
||||
btnText.textContent = "Waking up device...";
|
||||
statusMsg.textContent = "Sending Bluetooth activation command to the door module...";
|
||||
|
||||
// Send the command to turn ON Bluetooth on ESP32
|
||||
await fetch('api_wifi.php?action=trigger_bt_on');
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// STEP 2: Wait for ESP32 to confirm BT is active
|
||||
// Poll the server until bt_status == 'active'
|
||||
// Timeout after 30 seconds (ESP32 polls every ~800ms)
|
||||
// ══════════════════════════════════════════
|
||||
setStep(2);
|
||||
btnText.textContent = "Waiting for device...";
|
||||
statusMsg.textContent = "Waiting for the door module to activate Bluetooth. Please wait...";
|
||||
|
||||
const btReady = await waitForBtActive(30000); // 30 second max wait
|
||||
|
||||
if (!btReady) {
|
||||
throw new Error("The door module did not respond in time. Make sure the ESP32 is powered on and connected to the network.");
|
||||
}
|
||||
|
||||
statusMsg.className = 'status-msg success';
|
||||
statusMsg.textContent = "Door module is broadcasting! Connecting...";
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// STEP 3: Connect to BLE device
|
||||
// The browser picker will show identia_bt since
|
||||
// ESP32 is now actively advertising
|
||||
// ══════════════════════════════════════════
|
||||
setStep(3);
|
||||
btnText.textContent = "Connecting...";
|
||||
statusMsg.textContent = "A device picker will appear. Please select 'identia_bt' and click Pair.";
|
||||
|
||||
const device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ name: 'identia_bt' }],
|
||||
optionalServices: ['12345678-1234-5678-1234-56789abcdef0']
|
||||
});
|
||||
|
||||
statusMsg.textContent = `Establishing connection to ${device.name}...`;
|
||||
|
||||
const server = await device.gatt.connect();
|
||||
|
||||
// Connected!
|
||||
iconWrap.className = 'icon-wrap pulsing active';
|
||||
btIcon.className = "mdi mdi-bluetooth-transfer";
|
||||
statusMsg.className = 'status-msg success';
|
||||
statusMsg.textContent = "Connected successfully! Generating secure access token...";
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// STEP 4: Generate token and redirect
|
||||
// ══════════════════════════════════════════
|
||||
setStep(4);
|
||||
btnText.textContent = "Preparing access...";
|
||||
|
||||
const resp = await fetch('api_generate_door_token.php');
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
statusMsg.textContent = "Redirecting to verification portal...";
|
||||
btnText.textContent = "Redirecting...";
|
||||
// All 4 steps done
|
||||
document.querySelectorAll('.steps .step').forEach(s => s.className = 'step done');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = `192.168.1.20.php?token=${data.token}`;
|
||||
}, 600);
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to generate access token.");
|
||||
}
|
||||
|
||||
} catch(err) {
|
||||
// Return to normal UI
|
||||
btn.disabled = false;
|
||||
btnText.textContent = "Connect Bluetooth";
|
||||
btnIcon.style.display = 'inline-block';
|
||||
spinIcon.style.display = 'none';
|
||||
iconWrap.className = 'icon-wrap pulsing error';
|
||||
btIcon.className = "mdi mdi-bluetooth-off";
|
||||
resetSteps();
|
||||
|
||||
statusMsg.className = 'status-msg error';
|
||||
|
||||
if (err.name === 'NotFoundError') {
|
||||
statusMsg.textContent = 'Cancelled or device not found. Please try again.';
|
||||
} else if (err.name === 'SecurityError') {
|
||||
statusMsg.textContent = 'Permission denied. Ensure HTTPS is enabled or add this site to chrome://flags.';
|
||||
} else {
|
||||
statusMsg.textContent = err.message || 'Connection failed.';
|
||||
}
|
||||
|
||||
// Reset icon after 3 seconds
|
||||
setTimeout(() => {
|
||||
iconWrap.className = 'icon-wrap pulsing';
|
||||
btIcon.className = "mdi mdi-bluetooth";
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Poll the server until the ESP32 confirms Bluetooth is active.
|
||||
* @param {number} timeoutMs Maximum time to wait in milliseconds
|
||||
* @returns {Promise<boolean>} true if BT became active, false if timed out
|
||||
*/
|
||||
async function waitForBtActive(timeoutMs) {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 1000; // Check every 1 second
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch('api_wifi.php?action=get_bt_status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.bt_status === 'active') {
|
||||
// ESP32 has confirmed BT is on and advertising
|
||||
// Wait a tiny bit more for the BLE stack to stabilize
|
||||
await sleep(500);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update the status message with a visual countdown
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const remaining = Math.ceil((timeoutMs - (Date.now() - startTime)) / 1000);
|
||||
document.getElementById('statusMsg').textContent =
|
||||
`Waiting for the door module to activate Bluetooth... (${remaining}s remaining)`;
|
||||
|
||||
} catch(e) {
|
||||
console.error('Poll error:', e);
|
||||
}
|
||||
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
|
||||
return false; // Timed out
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function setStep(num) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const el = document.getElementById('step' + i);
|
||||
if (i < num) el.className = 'step done';
|
||||
else if (i === num) el.className = 'step active';
|
||||
else el.className = 'step';
|
||||
}
|
||||
}
|
||||
|
||||
function resetSteps() {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
document.getElementById('step' + i).className = 'step';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>404 Not Found - FiberID</title>
|
||||
<link rel="stylesheet" href="vendors/css/vendor.bundle.base.css">
|
||||
<link rel="shortcut icon" href="images/myn.png" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #ffffffff;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
max-width: 100%;
|
||||
width: 600px; /* Adjust based on image size */
|
||||
height: auto;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #2c2e33;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 15px;
|
||||
color: #6c7383;
|
||||
margin-bottom: 35px;
|
||||
max-width: 450px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-go-back {
|
||||
background-color: #4B49AC;
|
||||
color: #ffffff;
|
||||
padding: 12px 30px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
||||
display: inline-block;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-go-back:hover {
|
||||
background-color: #3f3e91;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 404 Illustration -->
|
||||
<img src="images/404_illustration.png" alt="404 Illustration" class="illustration">
|
||||
|
||||
<!-- Text Content -->
|
||||
<div class="error-title">This Page Does Not Exist</div>
|
||||
<div class="error-message">
|
||||
Sorry, the page you are looking for could not be found. It's just an accident that was not intentional.
|
||||
</div>
|
||||
|
||||
<!-- Go Back Button -->
|
||||
<a href="https://identia.montaklo.id/" class="btn-go-back">Go back</a>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
CHANGELOG
|
||||
=========
|
||||
|
||||
V 1.0.0
|
||||
-------
|
||||
- Initial release
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
// PushHelper.php
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Minishlink\WebPush\WebPush;
|
||||
use Minishlink\WebPush\Subscription;
|
||||
|
||||
class PushHelper {
|
||||
public static function sendPush($pdo, $id_account, $title, $body, $url = '/dashboard.php') {
|
||||
try {
|
||||
// Validate: only send push if account exists
|
||||
$checkStmt = $pdo->prepare("SELECT id_account FROM account WHERE id_account = ?");
|
||||
$checkStmt->execute([$id_account]);
|
||||
$accountData = $checkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$accountData) {
|
||||
return; // Account deleted
|
||||
}
|
||||
|
||||
|
||||
$keys = require __DIR__ . '/push_config.php';
|
||||
|
||||
$auth = [
|
||||
'VAPID' => [
|
||||
'subject' => $keys['VAPID_SUBJECT'],
|
||||
'publicKey' => $keys['VAPID_PUBLIC_KEY'],
|
||||
'privateKey' => $keys['VAPID_PRIVATE_KEY'],
|
||||
],
|
||||
];
|
||||
|
||||
// Instantiate WebPush
|
||||
$webPush = new WebPush($auth);
|
||||
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id_account INT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh VARCHAR(255) NOT NULL,
|
||||
auth VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
// Fetch subscriptions for this specific account
|
||||
$stmt = $pdo->prepare("SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE id_account = ?");
|
||||
$stmt->execute([$id_account]);
|
||||
$subs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$payload = json_encode([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'url' => $url,
|
||||
'icon' => '/images/myn.png'
|
||||
]);
|
||||
|
||||
foreach ($subs as $subData) {
|
||||
$subscription = Subscription::create([
|
||||
'endpoint' => $subData['endpoint'],
|
||||
'publicKey' => $subData['p256dh'], // corresponds to p256dh
|
||||
'authToken' => $subData['auth'], // corresponds to auth
|
||||
]);
|
||||
|
||||
// Queue the notification with High Urgency for immediate delivery
|
||||
$webPush->queueNotification($subscription, $payload, ['urgency' => 'high']);
|
||||
}
|
||||
|
||||
// Send all queued notifications
|
||||
foreach ($webPush->flush() as $report) {
|
||||
$endpoint = $report->getRequest()->getUri()->__toString();
|
||||
if (!$report->isSuccess()) {
|
||||
// If endpoint is expired/invalid, remove it from DB to clean up
|
||||
if ($report->isSubscriptionExpired()) {
|
||||
$del = $pdo->prepare("DELETE FROM push_subscriptions WHERE endpoint = ?");
|
||||
$del->execute([$endpoint]);
|
||||
}
|
||||
$errReason = "Push Failed for {$endpoint}: " . $report->getReason();
|
||||
file_put_contents(__DIR__ . '/push_error.log', "[" . date('Y-m-d H:i:s') . "] " . $errReason . "\n", FILE_APPEND);
|
||||
error_log($errReason);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$err = "[" . date('Y-m-d H:i:s') . "] PushHelper Fatal Error: " . $e->getMessage() . "\n";
|
||||
file_put_contents(__DIR__ . '/push_error.log', $err, FILE_APPEND);
|
||||
error_log($err);
|
||||
}
|
||||
}
|
||||
|
||||
public static function broadcastToAdmins($pdo, $title, $body, $url = '/dashboard.php') {
|
||||
// Broadcast to all subscribed accounts (admins)
|
||||
try {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id_account INT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh VARCHAR(255) NOT NULL,
|
||||
auth VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$stmt = $pdo->query("SELECT DISTINCT id_account FROM push_subscriptions WHERE id_account > 0");
|
||||
$admins = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($admins as $admin) {
|
||||
self::sendPush($pdo, $admin['id_account'], $title, $body, $url);
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
$err = "[" . date('Y-m-d H:i:s') . "] PushHelper DB Error: " . $e->getMessage() . "\n";
|
||||
file_put_contents(__DIR__ . '/push_error.log', $err, FILE_APPEND);
|
||||
error_log($err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# Skydash - Free Bootstrap Template
|
||||
|
||||
Skydash is the latest Bootstrap admin template from BootstrapDash. This template has been meticulously crafted to ensure the best possible experience for developers and users alike. This admin template is super easy to set up, modify and use. The clean design minimizes clutter and improves the overall user experience. Skydash is packed with all the features that fit your needs but not cramped with components you would not even use. It is an excellent fit to build admin panels, e-commerce systems, project management systems, CMS or CRM. It comes with a clean and well-commented code that makes it easy to work with the template. Thus making it an ideal pick for jump-starting your project.
|
||||
|
||||
<h1>Demo</h1>
|
||||
Click below to check out the live demo.
|
||||
|
||||
[](https://bootstrapdash.com/demo/skydash-free/template/)
|
||||
|
||||
|
||||
#### Credits:
|
||||
|
||||
- Bootstrap 4
|
||||
|
||||
- Font Awesome
|
||||
|
||||
- jQuery
|
||||
|
||||
- Gulp
|
||||
|
||||
- Chart.js
|
||||
|
||||
- Google Maps
|
||||
|
||||
- Perfect Scrollbar
|
||||
|
||||
|
||||
|
||||
#### Browser Support:
|
||||
|
||||
- Chrome (latest)
|
||||
|
||||
- FireFox (latest)
|
||||
|
||||
- Safari (latest)
|
||||
|
||||
- Opera (latest)
|
||||
|
||||
- IE10+
|
||||
|
||||
|
||||
#### License Information:
|
||||
|
||||
Skydash is released under MIT license. Skydash is a free Bootstrap 4 admin template developed from BootstrapDash. Feel free to download it, use it, share it, get creative with it.
|
||||
|
||||
#### How to use Skydash?
|
||||
|
||||
-Install node-gyp package. If you don’t know the installation steps, please click [here](https://github.com/nodejs/node-gyp)
|
||||
|
||||
- Click the Clone or Download button in GitHub and download as a ZIP file or you can enter the command git clone https://github.com/BootstrapDash/skydash-free-bootstrap-admin-template.git in your terminal to get a copy of this template.
|
||||
|
||||
- After the files have been downloaded you will get a folder with all the required files
|
||||
|
||||
- Open your terminal (Run as Administrator). You can install all the dependencies in the template by running the command npm install. All the required files are in the node modules. If you didn't run with admin authorities, you can see errors.
|
||||
|
||||
- Find the file named index.html, check what components you need. Open the file in a text editor and you can start editing.
|
||||
|
||||
- Now that your project has now kick-started, all you need to do now is to code, code, and code to your heart's content.
|
||||
|
||||
#### How to Contribute?:
|
||||
|
||||
We love your contributions and we welcome them wholeheartedly. We believe the more the merrier. To contribute make sure you have Node.js and npm installed. Now run the command gulp --version. If the command returns with the Gulp version number, it means you have Gulp installed. If not you need to run the command npm install --global gulp-cli to install Gulp.
|
||||
|
||||
|
||||
#### Next
|
||||
|
||||
After Gulp has been installed, follow the steps below to contribute.
|
||||
|
||||
- Fork and clone the repo.
|
||||
|
||||
- Run the command npm install to install all the dependencies.
|
||||
|
||||
- Enter the command gulp serve. This will open Skydash in your default browser.
|
||||
|
||||
- Make your valuable contribution.
|
||||
|
||||
- Submit a pull request.
|
||||
|
||||
|
||||
### More from BootstrapDash
|
||||
Here are some of our most popular templates:
|
||||
|
||||
- [StarAdmin Free Bootstrap Admin Template](https://github.com/BootstrapDash/StarAdmin-Free-Bootstrap-Admin-Template)
|
||||
- [PurpleAdmin Free Admin Template](https://github.com/BootstrapDash/PurpleAdmin-Free-Admin-Template)
|
||||
- [MajesticAdmin Free Bootstrap Admin Template](https://github.com/BootstrapDash/MajesticAdmin-Free-Bootstrap-Admin-Template)
|
||||
|
||||
### Like what you see?
|
||||
Please leave a star on our GitHub repo.
|
||||
Submit bugs and help us improve Corona Angular!
|
||||
Find us on
|
||||
- [Twitter](https://twitter.com/bootstrapdash?lang=en),
|
||||
- [Facebook](https://www.facebook.com/bootstrapdash/),
|
||||
- [Instagram](https://www.instagram.com/bootstrapdash/?hl=en),
|
||||
- [Behance](https://www.behance.net/bootstrapdash),
|
||||
- [Pinterest](https://www.pinterest.com/bootstrapdash/),
|
||||
- [Dribbble](https://dribbble.com/bootstrapdash),
|
||||
- [LinkedIn](https://in.linkedin.com/in/bootstrapdash)
|
||||
|
||||
|
||||
#### Go Premium!!
|
||||
|
||||
Do you need a template with more features and functionalities? Get more with our collection of premium templates with more plugins, eye-catching animations, UI components, and sample pages all fitting together with a high-quality design. Visit https://www.bootstrapdash.com for more admin templates.
|
||||
|
||||
Check out the premium version of [Skydash Bootstrap admin template](https://www.bootstrapdash.com/product/skydash-admin-template/)
|
||||
|
|
@ -0,0 +1,806 @@
|
|||
<?php
|
||||
include 'header.php';
|
||||
include 'koneksi.php';
|
||||
?>
|
||||
<div class="main-panel">
|
||||
<div class="content-wrapper">
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin">
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4 mb-xl-0">
|
||||
<h3 class="font-weight-bold">Access Control</h3>
|
||||
<h6 class="font-weight-normal mb-0">Manage occupant access and IoT door, <span class="text-primary">Manage access & door!</span></h6>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Tabel Occupant -->
|
||||
<div class="col-md-8 grid-margin">
|
||||
<div class="card" style="border: 1px solid #e2e8f0; border-radius: 10px;">
|
||||
<div class="card-body">
|
||||
<p class="card-title mb-0">Data Access Occupant</p>
|
||||
<br>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-borderless" id="myTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>Name</th>
|
||||
<th>Access</th>
|
||||
<th style="width:60px; text-align:center;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<?php
|
||||
$stmt = $pdo->query("SELECT * FROM occupant WHERE status = 'approved' ORDER BY id_occupant ASC");
|
||||
$occupants = $stmt->fetchAll();
|
||||
?>
|
||||
<tbody>
|
||||
<?php
|
||||
$no = 1;
|
||||
foreach($occupants as $row):
|
||||
$sc = ($no % 2 == 1) ? 'row-odd' : 'row-even';
|
||||
?>
|
||||
<tr class="<?= $sc; ?>">
|
||||
<td style="vertical-align:middle; font-weight:600; color:#555;"><?= $no++; ?></td>
|
||||
<td style="vertical-align:middle;"><div class="d-flex align-items-center"><span style="font-weight:500; font-size:14px; color:#212529; display:inline-flex; align-items:center;"><?= htmlspecialchars($row['name']); ?><?php
|
||||
$isVerified = !empty($row['name']) && !empty($row['nik_nip']) && !empty($row['no_hp']) && !empty($row['pin']) && !empty($row['foto']) && !empty($row['finger_id']) && $row['finger_id'] !== '-' && !empty($row['code_register']) && $row['progress'] === 'used' && $row['status'] === 'approved' && $row['access'] === 'active';
|
||||
if($isVerified): ?><i class="mdi mdi-check-decagram" style="color: #1da1f2; font-size: 16px; margin-left: 4px;" title="Verified Approved"></i><?php endif; ?></span></div></td>
|
||||
<td style="vertical-align:middle;">
|
||||
<select class="form-control form-control-sm access-dropdown" data-id="<?= $row['id_occupant']; ?>" style="border-radius: 6px; width: 120px; height: 35px; padding: 2px 10px; font-size: 13px;">
|
||||
<option value="active" <?= ($row['access'] == 'active') ? 'selected' : ''; ?>>Active</option>
|
||||
<option value="pending" <?= ($row['access'] == 'pending') ? 'selected' : ''; ?>>Pending</option>
|
||||
<option value="suspend" <?= ($row['access'] == 'suspend') ? 'selected' : ''; ?>>Suspend</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style="vertical-align:middle; text-align:center;">
|
||||
<button class="btn btn-sm btn-success text-white btn-confirm-access" data-id="<?= $row['id_occupant']; ?>" style="border-radius:6px; font-weight:bold; width:32px; padding: 4px 0; font-size:14px; display:inline-flex; align-items:center; justify-content:center;" disabled title="Click to save changes">
|
||||
<i class="mdi mdi-check"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IoT Door Control -->
|
||||
<div class="col-md-4 grid-margin">
|
||||
<!-- SMART LOCK SYSTEM CARD -->
|
||||
<div class="card iot-card" style="border: 1px solid #e2e8f0; border-radius: 10px; background: linear-gradient(145deg, #ffffff, #ffffffff); position: relative; overflow: hidden;">
|
||||
<!-- Decorative Background Elements -->
|
||||
<div style="position: absolute; top: -30px; right: -30px; width: 100px; height: 100px; border-radius: 50%; background: #ffffffff; opacity: 0.05;"></div>
|
||||
<div style="position: absolute; bottom: -20px; left: -20px; width: 80px; height: 80px; border-radius: 50%; background: #ffffffff; opacity: 0.05;"></div>
|
||||
|
||||
<div class="card-body d-flex flex-column align-items-center text-center py-4" style="z-index: 1;">
|
||||
<div style="background: #4b49ac; border-radius: 20px; padding: 6px 16px; margin-bottom: 24px;">
|
||||
<span style="color: #ffffffff; font-weight: 700; font-size: 11px; letter-spacing: 1px;">SMART LOCK SYSTEM</span>
|
||||
</div>
|
||||
|
||||
<div class="door-illustration mb-3 mt-2" style="position: relative;">
|
||||
<div class="door-frame" style="width: 120px; height: 150px; border: 4px solid #e2e8f0; border-bottom: none; border-radius: 8px 8px 0 0; margin: 0 auto; position: relative; background: #f1f5f9; display: flex; align-items: center; justify-content: center; overflow: hidden;">
|
||||
<i class="mdi mdi-door-closed text-primary door-icon-main" id="doorIcon" style="font-size: 100px; color: #4b49ac !important; transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275), color 0.3s; line-height: 1;"></i>
|
||||
<div class="door-glow" id="doorGlow" style="position: absolute; bottom: 0; left: 0; right: 0; height: 4px; background: transparent; transition: background 0.3s;"></div>
|
||||
</div>
|
||||
<div style="width: 140px; height: 4px; background: #cbd5e1; margin: 0 auto; border-radius: 2px;"></div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted px-2" style="font-size: 13px; line-height: 1.4; margin-bottom: 15px;">Confirmation is required to unlock the IoT door access.</p>
|
||||
|
||||
<div class="info-alert mb-4 w-100" style="background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 10px 12px; font-size: 12px; color: #1e293b; text-align: left;">
|
||||
<div class="info-alert-icon" style="width: 24px; height: 24px; border-radius: 50%; background: #1d4ed8; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i class="mdi mdi-information-variant" style="color: #e0ecff; font-size: 16px; line-height: 1;"></i></div>
|
||||
<div class="info-alert-text" style="flex: 1; line-height: 1.4;">
|
||||
Please click the <strong>Unlock Door</strong> button below to open the FTM Room Door.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-lg mt-auto w-100 unlock-btn" id="btnOpenDoor" style="border-radius: 10px; font-weight: 700; font-size: 14px; letter-spacing: 1px; padding: 12px; transition: all 0.2s; position: relative; overflow: hidden;">
|
||||
<span class="btn-text d-flex align-items-center justify-content-center" style="position: relative; z-index: 2;">
|
||||
<i class="mdi mdi-key mr-2" style="font-size: 20px;"></i> UNLOCK DOOR
|
||||
</span>
|
||||
<div class="btn-hover-effect" style="position: absolute; top: 0; left: -100%; width: 50%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transform: skewX(-20deg); transition: 0.5s;"></div>
|
||||
</button>
|
||||
<div id="scanStatus" class="mt-3 text-sm font-weight-bold" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: CONFIRM UNLOCK ==================== -->
|
||||
<div class="modal fade" id="modalVerifyPin" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content mdl-card">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title font-weight-bold"><i class="mdi mdi-door-open mr-1 text-primary"></i> Confirm Unlock</h6>
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<i class="mdi mdi-shield-check-outline" style="font-size:40px;color:#10b981;"></i>
|
||||
<p class="mt-2 mb-3" style="font-size:13px;color:#666;">Are you sure you want to unlock the door?</p>
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="button" class="btn btn-light btn-sm" data-dismiss="modal" style="width: 48%;">No</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btnConfirmUnlock" style="width: 48%;"><i class="mdi mdi-check mr-1"></i>Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: SECURITY VERIFICATION ==================== -->
|
||||
<div class="fp-modal-overlay" id="fpModal" style="display:none;">
|
||||
<div class="fp-modal-content">
|
||||
<!-- Countdown timer -->
|
||||
<div class="fp-countdown" id="fpCountdown" style="display:none;">
|
||||
<svg class="fp-countdown-svg" viewBox="0 0 36 36">
|
||||
<circle class="fp-countdown-bg" cx="18" cy="18" r="15.5"/>
|
||||
<circle class="fp-countdown-ring" id="fpCountdownRing" cx="18" cy="18" r="15.5"/>
|
||||
</svg>
|
||||
<span class="fp-countdown-num" id="fpCountdownNum">3</span>
|
||||
</div>
|
||||
<div class="fp-icon-wrapper" id="fpIconWrapper">
|
||||
<i class="mdi mdi-fingerprint" id="fpIcon"></i>
|
||||
</div>
|
||||
<div class="fp-title" id="fpTitle">Unlock Door</div>
|
||||
<div class="fp-text" id="fpStatusText">Touch your fingerprint sensor to unlock the door.</div>
|
||||
<div id="fpAlert" style="display:none; padding:10px; border-radius:8px; margin-bottom:16px; font-size:13px; text-align:center; border:1px solid transparent;"></div>
|
||||
<button class="fp-cancel-btn" type="button" id="fpCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: DOOR UNLOCKED ==================== -->
|
||||
<div class="fp-modal-overlay" id="doorModal" style="display:none;">
|
||||
<div class="fp-modal-content">
|
||||
<div class="fp-icon-wrapper fp-door-icon">
|
||||
<div class="fp-door-circle">
|
||||
<i class="mdi mdi-door-open"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-title" style="color:#10b981;">Door Unlocked</div>
|
||||
<div class="fp-text"><span id="doorWelcomeName"></span>The door has been successfully unlocked.<br>Remember to lock the door when you're done.</div>
|
||||
<button class="fp-lock-btn" type="button" id="btnLockDoor"><i class="mdi mdi-lock"></i> Lock Door</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Wrapper Ends -->
|
||||
<?php include 'footer.php'; ?>
|
||||
<?php include 'copy.php'; ?>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Initialize DataTable mimicking new_account.php
|
||||
if ($.fn.DataTable.isDataTable('#myTable')) {
|
||||
$('#myTable').DataTable().destroy();
|
||||
}
|
||||
$('#myTable').DataTable({ "order": [[0, 'asc']] });
|
||||
|
||||
// Track original values to enable/disable button
|
||||
var originalAccessValues = {};
|
||||
$('.access-dropdown').each(function() {
|
||||
originalAccessValues[$(this).data('id')] = $(this).val();
|
||||
});
|
||||
|
||||
// Handle Access Dropdown Change - Enable corresponding confirm button
|
||||
$('#myTable').on('change', '.access-dropdown', function() {
|
||||
var idOccupant = $(this).data('id');
|
||||
var currentValue = $(this).val();
|
||||
var confirmBtn = $('.btn-confirm-access[data-id="'+idOccupant+'"]');
|
||||
|
||||
if (currentValue !== originalAccessValues[idOccupant]) {
|
||||
confirmBtn.prop('disabled', false).removeClass('btn-secondary').addClass('btn-success');
|
||||
// Add a subtle pulse to attract attention
|
||||
confirmBtn.addClass('pulse-btn');
|
||||
} else {
|
||||
confirmBtn.prop('disabled', true).removeClass('pulse-btn');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Confirm Access Button Click
|
||||
$('#myTable').on('click', '.btn-confirm-access', function() {
|
||||
var btn = $(this);
|
||||
var idOccupant = btn.data('id');
|
||||
var selectElement = $('.access-dropdown[data-id="'+idOccupant+'"]');
|
||||
var accessValue = selectElement.val();
|
||||
|
||||
btn.prop('disabled', true).removeClass('pulse-btn').html('<i class="mdi mdi-loading mdi-spin"></i>');
|
||||
selectElement.prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: 'update_access_occupant.php',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
id_occupant: idOccupant,
|
||||
access: accessValue
|
||||
},
|
||||
success: function(response) {
|
||||
selectElement.prop('disabled', false);
|
||||
btn.html('<i class="mdi mdi-check"></i>');
|
||||
|
||||
if(response.status === 'success') {
|
||||
originalAccessValues[idOccupant] = accessValue; // Update original value
|
||||
showCustomAlert('Access successfully updated to ' + accessValue);
|
||||
|
||||
// Visual feedback on row
|
||||
var row = selectElement.closest('tr');
|
||||
row.css('background-color', '#ecfdf5');
|
||||
setTimeout(function() {
|
||||
row.css('background-color', '');
|
||||
}, 1000);
|
||||
} else {
|
||||
showCustomAlert(response.message || 'Failed to update access.');
|
||||
selectElement.val(originalAccessValues[idOccupant]); // Revert
|
||||
btn.prop('disabled', true);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
selectElement.prop('disabled', false);
|
||||
selectElement.val(originalAccessValues[idOccupant]); // Revert
|
||||
btn.prop('disabled', true).html('<i class="mdi mdi-check"></i>');
|
||||
showCustomAlert('Connection failed!');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setGlobalDoorLocked() {
|
||||
$.post('api_door_status.php', { action: 'lock' }, function(d) {
|
||||
if(d.status === 'success') {
|
||||
showCustomAlert('Door locked successfully!');
|
||||
closeDoorModal();
|
||||
updateUIFromState('locked');
|
||||
}
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
function setGlobalDoorUnlocked(name) {
|
||||
$.post('api_door_status.php', { action: 'unlock' }, function(d) {
|
||||
if(d.status === 'success') {
|
||||
// Close fingerprint modal and go straight to door modal
|
||||
closeFpModal();
|
||||
setTimeout(function() {
|
||||
showDoorUnlockedModal(name);
|
||||
updateUIFromState('unlocked');
|
||||
}, 300);
|
||||
}
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
$('.unlock-btn').hover(
|
||||
function() { if(!window.isDoorUnlocked) $(this).find('.btn-hover-effect').css('left', '100%'); },
|
||||
function() { if(!window.isDoorUnlocked) { $(this).find('.btn-hover-effect').css({'left': '-100%', 'transition': '0s'}); setTimeout(() => {$(this).find('.btn-hover-effect').css('transition', '0.5s');}, 50); } }
|
||||
);
|
||||
|
||||
// Handle main Door Button Click
|
||||
$('#btnOpenDoor').on('click', function() {
|
||||
if (window.isDoorUnlocked) {
|
||||
// Door is already unlocked, clicking it forces a LOCK
|
||||
setGlobalDoorLocked();
|
||||
} else {
|
||||
// Door is locked, proceed with unlock sequence
|
||||
$('#modalVerifyPin').modal('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Confirm Unlock Yes Button
|
||||
$('#btnConfirmUnlock').on('click', function() {
|
||||
var btn = $(this);
|
||||
var origHtml = btn.html();
|
||||
btn.prop('disabled', true).html('<i class="mdi mdi-loading mdi-spin"></i> Unlocking...');
|
||||
|
||||
$('#modalVerifyPin').modal('hide');
|
||||
btn.prop('disabled', false).html(origHtml);
|
||||
setTimeout(function() {
|
||||
setGlobalDoorUnlocked('<?= isset($_SESSION["name"]) ? addslashes($_SESSION["name"]) : "" ?>');
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// === Fingerprint Modal Functions ===
|
||||
var fpCountdownTimer = null;
|
||||
|
||||
function startCountdown(numEl, ringEl, containerEl, seconds, onComplete) {
|
||||
var remaining = seconds;
|
||||
$(containerEl).show();
|
||||
$(numEl).text(remaining);
|
||||
// Reset ring animation
|
||||
var $ring = $(ringEl);
|
||||
$ring.css({ 'animation': 'none', 'stroke-dashoffset': '0' });
|
||||
// Force reflow
|
||||
$ring[0].getBoundingClientRect();
|
||||
$ring.css('animation', 'fpCountdownStroke ' + seconds + 's linear forwards');
|
||||
|
||||
var timer = setInterval(function() {
|
||||
remaining--;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
$(containerEl).hide();
|
||||
if (onComplete) onComplete();
|
||||
} else {
|
||||
$(numEl).text(remaining);
|
||||
}
|
||||
}, 1000);
|
||||
return timer;
|
||||
}
|
||||
|
||||
function openFpModal() {
|
||||
// Reset state
|
||||
var $icon = $('#fpIcon');
|
||||
$icon.removeClass('mdi-check-circle mdi-close-circle mdi-loading mdi-spin fp-success fp-error').addClass('mdi-fingerprint fp-scanning');
|
||||
$icon.removeAttr('style');
|
||||
$('#fpIconWrapper').removeClass('fp-result-success fp-result-error');
|
||||
$('#fpTitle').text('Unlock Door').css('color', '#1f2937');
|
||||
$('#fpStatusText').text('Touch your fingerprint sensor to unlock the door.').show();
|
||||
$('#fpAlert').hide().html('');
|
||||
$('#fpCancelBtn').show();
|
||||
$('#fpCountdown').hide();
|
||||
if (fpCountdownTimer) { clearInterval(fpCountdownTimer); fpCountdownTimer = null; }
|
||||
// Show modal
|
||||
var $modal = $('#fpModal');
|
||||
$modal.css('display', 'flex').css('animation', 'fpFadeIn 0.2s ease-out forwards');
|
||||
}
|
||||
|
||||
function closeFpModal() {
|
||||
if (fpCountdownTimer) { clearInterval(fpCountdownTimer); fpCountdownTimer = null; }
|
||||
var $modal = $('#fpModal');
|
||||
$modal.css('animation', 'fpFadeOut 0.2s ease-out forwards');
|
||||
setTimeout(function() { $modal.css('display', 'none'); }, 200);
|
||||
}
|
||||
|
||||
function openDoorModal() {
|
||||
var $modal = $('#doorModal');
|
||||
$modal.css('display', 'flex').css('animation', 'fpFadeIn 0.2s ease-out forwards');
|
||||
}
|
||||
|
||||
function closeDoorModal() {
|
||||
var $modal = $('#doorModal');
|
||||
$modal.css('animation', 'fpFadeOut 0.2s ease-out forwards');
|
||||
setTimeout(function() { $modal.css('display', 'none'); }, 200);
|
||||
}
|
||||
|
||||
// Cancel / close handlers
|
||||
$('#fpCancelBtn').on('click', function() { closeFpModal(); });
|
||||
|
||||
function showFingerprintModal() {
|
||||
openFpModal();
|
||||
// Start WebAuthn after short delay
|
||||
setTimeout(startWebAuthnVerification, 600);
|
||||
}
|
||||
|
||||
// Switch to spin loader state
|
||||
function showVerifyingState() {
|
||||
var $icon = $('#fpIcon');
|
||||
$icon.removeClass('mdi-fingerprint fp-scanning').addClass('mdi-loading mdi-spin');
|
||||
// Reset gradient to solid color for spinner
|
||||
$icon.css({
|
||||
'background': 'none',
|
||||
'-webkit-background-clip': 'unset',
|
||||
'background-clip': 'unset',
|
||||
'-webkit-text-fill-color': '#4B49AC',
|
||||
'animation': 'none'
|
||||
});
|
||||
$icon.addClass('mdi-spin');
|
||||
$('#fpStatusText').text('Verifying your identity to unlock the door...');
|
||||
$('#fpCancelBtn').hide();
|
||||
}
|
||||
|
||||
async function startWebAuthnVerification() {
|
||||
if (!window.PublicKeyCredential) {
|
||||
showScanResult('error', 'Not Supported', 'Your browser does not support biometric unlock.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const challenge = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(challenge);
|
||||
|
||||
const publicKey = {
|
||||
challenge: challenge,
|
||||
rpId: window.location.hostname,
|
||||
userVerification: "required",
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("No fingerprint detected");
|
||||
}
|
||||
|
||||
const rawIdBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(credential.rawId)));
|
||||
|
||||
// Switch to spin loader
|
||||
showVerifyingState();
|
||||
|
||||
verifyFingerprintServer(rawIdBase64);
|
||||
|
||||
} catch (err) {
|
||||
showScanResult('error', 'Cancelled', 'Door unlock was cancelled.');
|
||||
}
|
||||
}
|
||||
|
||||
function verifyFingerprintServer(credentialId) {
|
||||
$.ajax({
|
||||
url: 'verify_door_fingerprint.php',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: { credential_id: credentialId },
|
||||
success: function(response) {
|
||||
if(response.status === 'success') {
|
||||
setGlobalDoorUnlocked(response.name);
|
||||
} else {
|
||||
showScanResult('error', 'Unlock Failed', response.message || 'You are not authorized to unlock this door.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
showScanResult('error', 'Connection Lost', 'Unable to reach the server. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showScanResult(type, title, message) {
|
||||
var $icon = $('#fpIcon');
|
||||
$icon.removeClass('mdi-fingerprint mdi-loading mdi-spin fp-scanning').css({
|
||||
'background': 'none',
|
||||
'-webkit-background-clip': 'unset',
|
||||
'background-clip': 'unset',
|
||||
'animation': 'none'
|
||||
});
|
||||
$('#fpCancelBtn').hide();
|
||||
|
||||
// Only error state uses this modal now
|
||||
$icon.addClass('mdi-close-circle fp-error');
|
||||
$icon.css('-webkit-text-fill-color', '#ef4444');
|
||||
$('#fpIconWrapper').addClass('fp-result-error');
|
||||
$('#fpTitle').text(title).css('color', '#ef4444');
|
||||
$('#fpStatusText').text(message);
|
||||
|
||||
// Start 3s countdown + auto close
|
||||
fpCountdownTimer = startCountdown('#fpCountdownNum', '#fpCountdownRing', '#fpCountdown', 3, function() {
|
||||
closeFpModal();
|
||||
});
|
||||
}
|
||||
|
||||
function showDoorUnlockedModal(name) {
|
||||
if (name) {
|
||||
$('#doorWelcomeName').text('Welcome, ' + name + '! ').show();
|
||||
} else {
|
||||
$('#doorWelcomeName').hide();
|
||||
}
|
||||
openDoorModal();
|
||||
}
|
||||
|
||||
// Handle Lock Door Button Click from Modal
|
||||
$(document).on('click', '#btnLockDoor', function() {
|
||||
setGlobalDoorLocked();
|
||||
});
|
||||
|
||||
// API Polling specific for Door State
|
||||
window.isDoorUnlocked = false; // Initial presumed state
|
||||
|
||||
function updateUIFromState(state) {
|
||||
if (state === 'unlocked') {
|
||||
window.isDoorUnlocked = true;
|
||||
|
||||
// Change Button to Red "LOCK DOOR"
|
||||
$('#btnOpenDoor').removeClass('btn-primary').addClass('btn-danger').css('box-shadow', 'none');
|
||||
$('#btnOpenDoor .btn-text').html('<i class="mdi mdi-lock mr-2" style="font-size: 20px;"></i> LOCK DOOR');
|
||||
|
||||
// Animate Door Icon to Open state
|
||||
$('#doorIcon').removeClass('mdi-door-closed').addClass('mdi-door-open');
|
||||
|
||||
// If we are showing the fingerprint modal by accident, hide it
|
||||
if ($('#fpModal').is(':visible') && $('#scanStatus').text() === '') {
|
||||
closeFpModal();
|
||||
}
|
||||
|
||||
// Always show door modal when door is unlocked (persists on refresh)
|
||||
if (!$('#doorModal').is(':visible')) {
|
||||
openDoorModal();
|
||||
}
|
||||
} else {
|
||||
window.isDoorUnlocked = false;
|
||||
|
||||
// Revert Button to Standard Blue "UNLOCK DOOR"
|
||||
$('#btnOpenDoor').removeClass('btn-danger').addClass('btn-primary').css('box-shadow', '');
|
||||
$('#btnOpenDoor .btn-text').html('<i class="mdi mdi-key mr-2" style="font-size: 20px;"></i> UNLOCK DOOR');
|
||||
|
||||
// Animate Door Icon Back
|
||||
$('#doorIcon').removeClass('mdi-door-open').addClass('mdi-door-closed');
|
||||
|
||||
// Hide door modal if visible (door was locked externally)
|
||||
if ($('#doorModal').is(':visible')) {
|
||||
closeDoorModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pollDoorStatus() {
|
||||
$.get('api_door_status.php', function(data) {
|
||||
if (data && data.status === 'success') {
|
||||
updateUIFromState(data.door_state);
|
||||
}
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
// Poll every 2.5 seconds
|
||||
setInterval(pollDoorStatus, 2500);
|
||||
|
||||
// Run once immediately on load
|
||||
pollDoorStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── MANUAL STRIPES & BORDERS ── */
|
||||
#myTable { border-spacing: 0 12px !important; border-collapse: separate !important; }
|
||||
#myTable thead th { border-bottom: 1px solid #e2e8f0 !important; padding-bottom: 8px !important; }
|
||||
#myTable tbody tr td { border-bottom: none !important; padding-top: 6px !important; padding-bottom: 6px !important; }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; font-weight: 600; font-size: 11px; padding: 3px 12px; border-radius: 20px; }
|
||||
.pill-green { background: #e8f5e9; color: #2e7d32; }
|
||||
.pill-red { background: #ffebee; color: #c62828; }
|
||||
.pill-amber { background: #fff8e1; color: #f57f17; }
|
||||
.pill-gray { background: #f5f5f5; color: #757575; }
|
||||
|
||||
.mdl-card { border: none; border-radius: 6px; box-shadow: 0 8px 30px rgba(0,0,0,0.1); }
|
||||
.confirm-circle { width: 50px; height: 50px; border-radius: 50%; border: 2.5px solid #ef5350; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; }
|
||||
.confirm-circle span { font-size: 28px; color: #ef5350; line-height: 1; }
|
||||
.delete-ok-msg { display: none; margin-top: 12px; font-size: 13px; color: #43a047; font-weight: 500; }
|
||||
.frm-lbl { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 3px; }
|
||||
.pin-warning { display: flex; align-items: flex-start; gap: 8px; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #92400e; }
|
||||
.pin-warning i { font-size: 18px; color: #f59e0b; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
.eye-toggle { cursor: pointer; background: #fff; border-left: none; }
|
||||
.eye-toggle:hover i { color: #4b7bec; }
|
||||
.info-alert { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: #1e293b; }
|
||||
.info-alert-icon { width: 22px; height: 22px; border-radius: 50%; background: #1d4ed8; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.info-alert-icon i { color: #e0ecff; font-size: 14px; line-height: 1; }
|
||||
|
||||
/* Button Pulse Animation */
|
||||
@keyframes pulse-green {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
.pulse-btn {
|
||||
animation: pulse-green 2s infinite;
|
||||
}
|
||||
|
||||
/* ── Modal Styles (matching login.php) ── */
|
||||
.fp-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.fp-modal-content {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
|
||||
border-radius: 12px;
|
||||
padding: 30px 24px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
max-width: 320px;
|
||||
width: 90%;
|
||||
animation: fpPopIn 0.2s ease-out forwards;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fp-icon-wrapper {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Fingerprint scanning state — solid color + light sweep + pulse */
|
||||
.fp-icon-wrapper i.fp-scanning {
|
||||
font-size: 100px;
|
||||
background: linear-gradient(180deg, #4B49AC 40%, rgba(255,255,255,0.5) 50%, #4B49AC 60%);
|
||||
background-size: 100% 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: fpSweep 2.5s linear infinite, fpPulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* Spin loader state */
|
||||
.fp-icon-wrapper i.mdi-loading {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
/* Success icon */
|
||||
.fp-icon-wrapper i.fp-success {
|
||||
font-size: 100px;
|
||||
animation: fpSuccessPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
.fp-result-success {
|
||||
animation: fpGlowGreen 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Error icon */
|
||||
.fp-icon-wrapper i.fp-error {
|
||||
font-size: 100px;
|
||||
animation: fpShake 0.5s ease forwards;
|
||||
}
|
||||
.fp-result-error {
|
||||
animation: fpGlowRed 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.fp-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.fp-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fp-cancel-btn {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #4b5563;
|
||||
padding: 8px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
.fp-cancel-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Door icon style */
|
||||
.fp-door-icon {
|
||||
margin-bottom: 24px !important;
|
||||
}
|
||||
.fp-door-circle {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
animation: fpSuccessPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
.fp-door-circle i {
|
||||
font-size: 44px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fp-lock-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.fp-lock-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Countdown Timer ── */
|
||||
.fp-countdown {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
z-index: 10;
|
||||
}
|
||||
.fp-countdown-svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.fp-countdown-bg {
|
||||
fill: none;
|
||||
stroke: #e5e7eb;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
.fp-countdown-ring {
|
||||
fill: none;
|
||||
stroke: #4B49AC;
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 97.4;
|
||||
stroke-dashoffset: 0;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.fp-countdown-num {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #4B49AC;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@keyframes fpCountdownStroke {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: 97.4; }
|
||||
}
|
||||
|
||||
/* ── Keyframes ── */
|
||||
@keyframes fpFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fpFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes fpPopIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes fpSweep {
|
||||
0% { background-position: 0 120%; }
|
||||
100% { background-position: 0 -20%; }
|
||||
}
|
||||
@keyframes fpPulse {
|
||||
0% { transform: scale(0.95); opacity: 0.8; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
100% { transform: scale(0.95); opacity: 0.8; }
|
||||
}
|
||||
@keyframes fpSuccessPop {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
60% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes fpShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
@keyframes fpGlowGreen {
|
||||
0% { background: transparent; }
|
||||
50% { background: radial-gradient(circle, rgba(16,185,129,0.1) 0%, transparent 70%); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
@keyframes fpGlowRed {
|
||||
0% { background: transparent; }
|
||||
50% { background: radial-gradient(circle, rgba(239,68,68,0.08) 0%, transparent 70%); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
</style>
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save_account') {
|
||||
$id = $_POST['id_account'] ?? '';
|
||||
$name = $_POST['name'] ?? '';
|
||||
$username = $_POST['username'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
try {
|
||||
if (empty($id)) {
|
||||
if (empty($password)) {
|
||||
echo json_encode(['status'=>'error', 'message'=>'Password is required for new account.']);
|
||||
exit;
|
||||
}
|
||||
$enc = password_hash($password, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare("INSERT INTO account (name, username, password, password_enc, status) VALUES (?, ?, ?, ?, 'approved')");
|
||||
$stmt->execute([$name, $username, $enc, $password]);
|
||||
} else {
|
||||
if (!empty($password)) {
|
||||
$enc = password_hash($password, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare("UPDATE account SET name=?, username=?, password=?, password_enc=? WHERE id_account=?");
|
||||
$stmt->execute([$name, $username, $enc, $password, $id]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("UPDATE account SET name=?, username=? WHERE id_account=?");
|
||||
$stmt->execute([$name, $username, $id]);
|
||||
}
|
||||
}
|
||||
echo json_encode(['status'=>'success', 'message' => 'Account saved successfully']);
|
||||
} catch(Exception $e) {
|
||||
echo json_encode(['status'=>'error', 'message'=>$e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
?>
|
||||
<!-- partial -->
|
||||
<div class="main-panel">
|
||||
<div class="content-wrapper">
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin">
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4 mb-xl-0">
|
||||
<h3 class="font-weight-bold">Account Operations</h3>
|
||||
<h6 class="font-weight-normal mb-0">Manage operational account data, <span class="text-primary">Managing active accounts!</span></h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== CARD 1: FORM ==================== -->
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin stretch-card">
|
||||
<div class="card" style="border:1px solid #e5e9f0;box-shadow:none;border-radius:12px;">
|
||||
<div class="card-body" style="padding:28px;">
|
||||
<p class="card-title mb-0" id="formTitle">Add Account</p>
|
||||
<p class="text-muted mt-1 mb-4" style="font-size:12px;" id="formSubtitle">Create a new account.</p>
|
||||
|
||||
<form id="formAccount">
|
||||
<input type="hidden" name="action" value="save_account">
|
||||
<input type="hidden" name="id_account" id="input_id_account" value="">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="frm-lbl">Name</label>
|
||||
<input type="text" name="name" id="input_name" class="form-control form-control-sm" required style="border-color:#e5e9f0; height:38px;">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="frm-lbl">Username</label>
|
||||
<input type="text" name="username" id="input_username" class="form-control form-control-sm" required style="border-color:#e5e9f0; height:38px;">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="frm-lbl">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password" id="input_password" class="form-control form-control-sm" style="border-color:#e5e9f0; height:38px;">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text eye-toggle bg-white" data-target="input_password" style="cursor: pointer; border-radius: 0 4px 4px 0; border: 1px solid #e5e9f0; border-left: none; height: 38px; display: flex; align-items: center; padding: 0 12px;"><i class="mdi mdi-eye-off text-muted"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-12 text-right">
|
||||
<button type="button" class="btn btn-light btn-sm mr-2" id="btnCancelEdit" style="display:none; font-weight:bold; border-radius:6px;" onclick="cancelEdit()">Cancel Edit</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm" id="btnSaveAccount" style="font-weight:bold; border-radius:6px; padding:8px 16px;"><i class="mdi mdi-content-save mr-1"></i>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== CARD 2: TABLE ==================== -->
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin stretch-card">
|
||||
<div class="card" style="border: 1px solid #e2e8f0; border-radius: 10px;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<p class="card-title mb-0">Data Account</p>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-light" id="btnSelectMode"
|
||||
style="font-weight:bold; border-radius:6px; padding: 6px 10px; font-size:16px;"
|
||||
title="Select Multiple"><i class="mdi mdi-checkbox-multiple-marked-outline"></i></button>
|
||||
<button class="btn btn-sm btn-danger ml-2" id="btnBulkDelete"
|
||||
style="display:none; font-weight:bold; border-radius:6px; padding: 6px 10px; font-size:16px;"
|
||||
title="Delete Selected"><i class="mdi mdi-delete"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-borderless" id="myTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>Name</th>
|
||||
<th>Username</th>
|
||||
<th style="width:80px; text-align:center;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<?php
|
||||
$stmt = $pdo->query("SELECT * FROM account ORDER BY id_account ASC");
|
||||
$accounts = $stmt->fetchAll();
|
||||
?>
|
||||
<tbody>
|
||||
<?php
|
||||
$no = 1;
|
||||
foreach ($accounts as $row):
|
||||
$sc = ($no % 2 == 1) ? 'row-odd' : 'row-even';
|
||||
?>
|
||||
<tr class="<?= $sc; ?>" id="row_<?= $row['id_account']; ?>">
|
||||
<td style="vertical-align:middle; font-weight:600; color:#555;">
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<?= $no++; ?>
|
||||
<?php if(isset($_SESSION['user_id']) && $row['id_account'] == $_SESSION['user_id']): ?>
|
||||
<i class="mdi mdi-star" style="color: #ffda07ff; font-size: 10px; position: absolute; top: -4px; right: -8px; line-height: 1;" title="Your Account"></i>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="vertical-align:middle;">
|
||||
<span style="font-weight:500; font-size:14px; color:#212529; display:inline-flex; align-items:center;"><?= htmlspecialchars($row['name']); ?></span>
|
||||
</td>
|
||||
<td style="vertical-align:middle; font-size:13px; color:#495057;">
|
||||
<?= htmlspecialchars($row['username'] ?: '-'); ?>
|
||||
</td>
|
||||
<td style="vertical-align:middle; text-align:center; white-space:nowrap;">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<div class="dropdown btn-row-delete">
|
||||
<button class="btn btn-sm dropdown-dots-btn" type="button" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false" style="padding: 4px 8px;">
|
||||
<i class="mdi mdi-dots-vertical"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right"
|
||||
style="border-radius: 5px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); border: 1px solid #f1f2f4; min-width: 150px; padding: 0; overflow: hidden;">
|
||||
|
||||
<button class="dropdown-item d-flex align-items-center py-3 px-4 btn-custom-dropdown" type="button"
|
||||
onclick="editAccount('<?= $row['id_account']; ?>','<?= htmlspecialchars(addslashes($row['name'])); ?>','<?= htmlspecialchars(addslashes($row['username'])); ?>')">
|
||||
<span style="font-size: 14px; font-weight: 400; color: #1f2125;">Edit Data</span>
|
||||
</button>
|
||||
<button class="dropdown-item d-flex align-items-center py-3 px-4 btn-custom-dropdown" type="button"
|
||||
onclick="openDeleteModal('<?= $row['id_account']; ?>','<?= htmlspecialchars(addslashes($row['name'])); ?>')">
|
||||
<span style="font-size: 14px; font-weight: 400; color: #ff4d4f;">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" class="bulk-cb" value="<?= $row['id_account']; ?>"
|
||||
style="display:none; transform: scale(1.5); cursor:pointer; margin-left:10px; margin-top:0;">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: DELETE ==================== -->
|
||||
<div class="modal fade" id="modalDeleteAccount" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content mdl-card">
|
||||
<form id="formDeleteAccount">
|
||||
<div class="modal-body p-4" style="text-align: left;">
|
||||
<input type="hidden" name="id_account" id="delete_id_account">
|
||||
<h5 class="font-weight-bold mb-3">Delete Account</h5>
|
||||
<p style="font-size:14px;color:#666;margin-bottom:20px;">Are you sure you want to delete <strong id="delete_account_name"></strong>?</p>
|
||||
|
||||
<div class="custom-control custom-checkbox mb-4" style="display:flex; align-items:center;">
|
||||
<input type="checkbox" class="custom-control-input delete-confirm-cb" id="deleteConfirmCheckAccount" required>
|
||||
<label class="custom-control-label" for="deleteConfirmCheckAccount" style="font-size:14px; color:#1e1f23; padding-top:2px;">I understand that all of my data will be deleted</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end" style="gap:8px;">
|
||||
<button type="button" class="btn btn-light font-weight-bold" style="padding:8px 18px;font-size:13px;" data-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger font-weight-bold" style="padding:8px 18px;font-size:13px;" id="btnDeleteAccount" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var cb = document.getElementById('deleteConfirmCheckAccount');
|
||||
var btn = document.getElementById('btnDeleteAccount');
|
||||
if(cb && btn){
|
||||
cb.addEventListener('change', function(){ btn.disabled = !this.checked; });
|
||||
}
|
||||
$('#modalDeleteAccount').on('hidden.bs.modal', function(){ if(cb){cb.checked=false;} if(btn){btn.disabled=true;} });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ========================== CSS ========================== -->
|
||||
<style>
|
||||
.btn-custom-dashboard { background-color: #fff !important; border: 1px solid #e5e9f0 !important; color: #4b49ac !important; }
|
||||
.btn-custom-dashboard:hover, .btn-custom-dashboard:focus, .btn-custom-dashboard:active { background-color: #f8f9fa !important; border: 1px solid #e5e9f0 !important; color: #4b49ac !important; box-shadow: none !important; }
|
||||
.dropdown-dots-btn { background: transparent !important; border: none !important; box-shadow: none !important; }
|
||||
.dropdown-dots-btn:hover, .dropdown-dots-btn:focus, .dropdown-dots-btn:active { background: transparent !important; border: none !important; box-shadow: none !important; outline: none !important; }
|
||||
.dropdown-dots-btn i { font-size: 28px; color: #495057; }
|
||||
#myTable thead th { border-bottom: 1px solid #e2e8f0 !important; }
|
||||
#myTable tbody tr td { border-bottom: none !important; }
|
||||
.mdl-card { border: none; border-radius: 6px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1); }
|
||||
.frm-lbl { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 3px; }
|
||||
.btn-custom-dropdown:hover, .btn-custom-dropdown:focus { background-color: #ededed !important; }
|
||||
.delete-confirm-cb:checked ~ .custom-control-label::before { background-color: #ff4747 !important; border-color: #ff4747 !important; }
|
||||
.row-highlight { background-color: #f1f5f9 !important; }
|
||||
</style>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
<?php include 'copy.php'; ?>
|
||||
|
||||
<!-- ========================== JS ========================== -->
|
||||
<script>
|
||||
var isEditing = false;
|
||||
|
||||
// Edit - populate form
|
||||
function editAccount(id, name, username) {
|
||||
if (isEditing && document.getElementById('input_id_account').value === id) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('input_id_account').value = id;
|
||||
document.getElementById('input_name').value = name;
|
||||
document.getElementById('input_username').value = username;
|
||||
document.getElementById('input_password').value = '';
|
||||
document.getElementById('input_password').required = false;
|
||||
document.getElementById('input_password').readOnly = true;
|
||||
|
||||
document.getElementById('formTitle').innerText = 'Edit Account';
|
||||
document.getElementById('formSubtitle').innerText = 'Update details for ' + name;
|
||||
document.getElementById('btnCancelEdit').style.display = 'inline-block';
|
||||
|
||||
isEditing = true;
|
||||
|
||||
// Scroll to form
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
// Highlight the row being edited
|
||||
document.querySelectorAll('#myTable tbody tr').forEach(function(tr) {
|
||||
tr.classList.remove('row-highlight');
|
||||
});
|
||||
var editRow = document.getElementById('row_' + id);
|
||||
if (editRow) {
|
||||
editRow.classList.add('row-highlight');
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel edit
|
||||
function cancelEdit() {
|
||||
document.getElementById('formAccount').reset();
|
||||
document.getElementById('input_id_account').value = '';
|
||||
document.getElementById('input_password').required = true;
|
||||
document.getElementById('input_password').readOnly = false;
|
||||
|
||||
document.getElementById('formTitle').innerText = 'Add Account';
|
||||
document.getElementById('formSubtitle').innerText = 'Create a new account.';
|
||||
document.getElementById('btnCancelEdit').style.display = 'none';
|
||||
isEditing = false;
|
||||
|
||||
// Remove row highlight
|
||||
document.querySelectorAll('#myTable tbody tr').forEach(function(tr) {
|
||||
tr.classList.remove('row-highlight');
|
||||
});
|
||||
}
|
||||
|
||||
// Save account
|
||||
document.getElementById('formAccount').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var btn = document.getElementById('btnSaveAccount');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="mdi mdi-loading mdi-spin mr-1"></i>Saving...';
|
||||
|
||||
var fd = new FormData(this);
|
||||
|
||||
fetch('account.php', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="mdi mdi-content-save mr-1"></i>Save';
|
||||
if (d.status === 'success') {
|
||||
if (typeof showCustomAlert === 'function') showCustomAlert('Success', d.message, 'success');
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} else {
|
||||
if (typeof showCustomAlert === 'function') showCustomAlert('Failed', d.message || 'Failed to save data.', 'error');
|
||||
else alert(d.message || 'Failed to save data.');
|
||||
}
|
||||
}).catch(function() {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="mdi mdi-content-save mr-1"></i>Save';
|
||||
alert('Connection to server failed.');
|
||||
});
|
||||
});
|
||||
|
||||
function openDeleteModal(id, name) {
|
||||
document.getElementById("delete_id_account").value = id;
|
||||
document.getElementById("delete_account_name").innerText = name;
|
||||
$("#modalDeleteAccount").modal("show");
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
// Close dropdown on scroll
|
||||
window.addEventListener('scroll', function() {
|
||||
if ($('.dropdown-menu.show').length > 0) {
|
||||
$('[data-toggle="dropdown"]').attr('aria-expanded', 'false');
|
||||
$('.dropdown-menu').removeClass('show');
|
||||
$('.dropdown').removeClass('show');
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.isSelectMode = false;
|
||||
$('#btnSelectMode').on('click', function () {
|
||||
window.isSelectMode = !window.isSelectMode;
|
||||
if (window.isSelectMode) {
|
||||
$(this).html('<i class="mdi mdi-close"></i>').removeClass('btn-outline-secondary').addClass('btn-light').attr('title', 'Cancel Selection');
|
||||
$('#btnBulkDelete').css('display', 'inline-block');
|
||||
$('.btn-row-delete').hide();
|
||||
$('.bulk-cb').css('display', 'inline-block').prop('checked', false);
|
||||
} else {
|
||||
$(this).html('<i class="mdi mdi-checkbox-multiple-marked-outline"></i>').removeClass('btn-light').addClass('btn-outline-secondary').attr('title', 'Select Multiple');
|
||||
$('#btnBulkDelete').hide();
|
||||
$('.btn-row-delete').css('display', 'inline-block');
|
||||
$('.bulk-cb').hide().prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
$('#btnBulkDelete').on('click', function () {
|
||||
const selected = $('.bulk-cb:checked').map(function () { return $(this).val(); }).get();
|
||||
if (selected.length === 0) {
|
||||
if (typeof showCustomAlert === 'function') showCustomAlert('Please select at least one item to delete!', 'warning');
|
||||
else alert('Please select at least one item to delete!');
|
||||
return;
|
||||
}
|
||||
window.bulkDeleteIds = selected;
|
||||
document.getElementById("delete_id_account").value = "BULK";
|
||||
document.getElementById("delete_account_name").innerText = selected.length + " items selected";
|
||||
$("#modalDeleteAccount").modal("show");
|
||||
});
|
||||
|
||||
$("#formDeleteAccount").submit(function (e) {
|
||||
e.preventDefault();
|
||||
var val = $("#delete_id_account").val();
|
||||
if (val === "BULK") {
|
||||
$("#btnDeleteAccount").prop("disabled", true);
|
||||
$("#btnDeleteAccount").html('<i class="mdi mdi-loading mdi-spin mr-1"></i>Deleting...');
|
||||
let promises = window.bulkDeleteIds.map(id => {
|
||||
let fd = new FormData();
|
||||
fd.append('id_account', id);
|
||||
return fetch('delete_account.php', { method: 'POST', body: fd }).then(r => r.json());
|
||||
});
|
||||
Promise.all(promises).then(() => {
|
||||
setTimeout(function () { $("#modalDeleteAccount").modal("hide"); location.reload(); }, 1200);
|
||||
});
|
||||
} else {
|
||||
$.post("delete_account.php", $(this).serialize(), function (d) {
|
||||
if (d.status === "success") {
|
||||
$("#btnDeleteAccount").prop("disabled", true);
|
||||
setTimeout(function () { $("#modalDeleteAccount").modal("hide"); location.reload(); }, 1200);
|
||||
} else {
|
||||
if (typeof showCustomAlert === 'function') showCustomAlert(d.message);
|
||||
else alert(d.message);
|
||||
}
|
||||
}, "json");
|
||||
}
|
||||
});
|
||||
|
||||
if ($.fn.DataTable.isDataTable('#myTable')) {
|
||||
$('#myTable').DataTable().destroy();
|
||||
}
|
||||
var table = $('#myTable').DataTable({
|
||||
"columnDefs": [{ "orderable": false, "targets": 3 }, { "width": "40px", "targets": 3 }],
|
||||
"order": [[0, 'asc']]
|
||||
});
|
||||
|
||||
// Eye Toggle Logic
|
||||
$('.eye-toggle').on('click', function () {
|
||||
var targetId = $(this).data('target');
|
||||
var input = $('#' + targetId);
|
||||
var icon = $(this).find('i');
|
||||
|
||||
if (input.attr('type') === 'password') {
|
||||
input.attr('type', 'text');
|
||||
icon.removeClass('mdi-eye-off').addClass('mdi-eye');
|
||||
} else {
|
||||
input.attr('type', 'password');
|
||||
icon.removeClass('mdi-eye').addClass('mdi-eye-off');
|
||||
}
|
||||
});
|
||||
|
||||
// Make password required for new records
|
||||
document.getElementById('input_password').required = true;
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE account ADD COLUMN finger_device VARCHAR(255) NULL AFTER finger_id");
|
||||
echo "Column finger_device added to account table.";
|
||||
} catch (PDOException $e) {
|
||||
if (strpos($e->getMessage(), 'Duplicate column name') !== false) {
|
||||
echo "Column finger_device already exists.";
|
||||
} else {
|
||||
echo "Error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
include 'notification_helper.php';
|
||||
require_once 'PushHelper.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$statusFile = 'door_status.txt';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'lock') {
|
||||
file_put_contents($statusFile, 'locked');
|
||||
send_notification($pdo, 'mdi-door-closed-lock', 'Door Locked', 'The FTM room door has been successfully locked.', 'System detected that the door is now closed and locked.', false);
|
||||
echo json_encode(["status" => "success", "door_state" => "locked"]);
|
||||
} elseif ($action === 'unlock') {
|
||||
file_put_contents($statusFile, 'unlocked');
|
||||
send_notification($pdo, 'mdi-door-open', 'Door Unlocked', 'The FTM room door has been opened.', 'System detected that the door is currently open.', false);
|
||||
PushHelper::broadcastToAdmins($pdo, 'Door Unlocked', 'The FTM room door was accessed. Please ensure it is securely locked afterwards.', '/access.php');
|
||||
echo json_encode(["status" => "success", "door_state" => "unlocked"]);
|
||||
} else {
|
||||
echo json_encode(["status" => "error", "message" => "Aksi tidak dikenal"]);
|
||||
}
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$state = 'locked'; // Default
|
||||
$last_time = time();
|
||||
$day_mapping = [
|
||||
'Sunday' => 'Minggu', 'Monday' => 'Senin', 'Tuesday' => 'Selasa',
|
||||
'Wednesday' => 'Rabu', 'Thursday' => 'Kamis', 'Friday' => 'Jumat', 'Saturday' => 'Sabtu'
|
||||
];
|
||||
$month_mapping = [
|
||||
1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April', 5 => 'Mei', 6 => 'Juni',
|
||||
7 => 'Juli', 8 => 'Agustus', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
|
||||
];
|
||||
|
||||
if (file_exists($statusFile)) {
|
||||
$state = trim(file_get_contents($statusFile));
|
||||
$last_time = filemtime($statusFile);
|
||||
}
|
||||
|
||||
if (!date_default_timezone_get() || date_default_timezone_get() != 'Asia/Jakarta') {
|
||||
date_default_timezone_set('Asia/Jakarta');
|
||||
}
|
||||
|
||||
$hari = $day_mapping[date('l', $last_time)];
|
||||
$tanggal = date('d', $last_time);
|
||||
$bln = $month_mapping[(int)date('n', $last_time)];
|
||||
$thn = date('Y', $last_time);
|
||||
|
||||
$date_formatted = "$hari, $tanggal $bln $thn";
|
||||
$time_formatted = date('H:i:s', $last_time);
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"door_state" => $state,
|
||||
"last_updated_time" => $time_formatted,
|
||||
"last_updated_date" => $date_formatted
|
||||
]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$tokensFile = __DIR__ . '/door_tokens.json';
|
||||
$tokens = [];
|
||||
|
||||
// Read existing tokens
|
||||
if (file_exists($tokensFile)) {
|
||||
$content = file_get_contents($tokensFile);
|
||||
if ($content) {
|
||||
$tokens = json_decode($content, true) ?: [];
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup expired tokens (> 5 minutes old)
|
||||
$currentTime = time();
|
||||
$activeTokens = [];
|
||||
foreach ($tokens as $token => $data) {
|
||||
if (($currentTime - $data['created_at']) <= 300) { // 5 minutes
|
||||
if (!isset($data['used']) || !$data['used']) {
|
||||
$activeTokens[$token] = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new random token
|
||||
$newToken = bin2hex(random_bytes(16)); // 32 hex chars
|
||||
|
||||
$activeTokens[$newToken] = [
|
||||
'created_at' => $currentTime,
|
||||
'used' => false
|
||||
];
|
||||
|
||||
// Save back
|
||||
file_put_contents($tokensFile, json_decode('{}')); // clear out file first securely?
|
||||
file_put_contents($tokensFile, json_encode($activeTokens, JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'token' => $newToken,
|
||||
'expires_in' => 300
|
||||
]);
|
||||
?>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = $_POST['username'] ?? '';
|
||||
|
||||
if (empty($username)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Username is required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT foto, pin FROM account WHERE username = ?");
|
||||
$stmt->execute([$username]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user && !empty($user['foto'])) {
|
||||
// Check if foto file exists locally
|
||||
if (file_exists(__DIR__ . '/' . $user['foto'])) {
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'foto' => $user['foto'],
|
||||
'pin' => $user['pin']
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Photo file not found on server.']);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'No reference photo found for this account.']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Database error.']);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid request method.']);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
// Ambil data occupant yang di-approve dan memiliki foto
|
||||
$stmt = $pdo->query("SELECT id_occupant, name, foto FROM occupant WHERE status = 'approved' AND foto IS NOT NULL AND foto != ''");
|
||||
$occupants = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$faces = [];
|
||||
foreach ($occupants as $row) {
|
||||
$photoUrl = $row['foto'];
|
||||
// Jika tidak ada / di awal, biarkan sesuai relative path
|
||||
if (!file_exists(__DIR__ . '/' . $photoUrl)) {
|
||||
continue; // file fisik foto tidak ditemukan, skip.
|
||||
}
|
||||
$faces[] = [
|
||||
'id' => $row['id_occupant'],
|
||||
'name' => $row['name'],
|
||||
'foto' => $photoUrl
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $faces]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Query error: ' . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
include 'koneksi.php';
|
||||
|
||||
function getDeviceDetail($ua) {
|
||||
$detail = ['type' => 'Unknown', 'brand' => '', 'os' => ''];
|
||||
$uaLower = strtolower($ua);
|
||||
|
||||
// Detect OS
|
||||
if (strpos($uaLower, 'windows nt 10') !== false) {
|
||||
$detail['os'] = 'Windows 10/11';
|
||||
$detail['type'] = 'Windows PC';
|
||||
} elseif (strpos($uaLower, 'windows nt 6.3') !== false) {
|
||||
$detail['os'] = 'Windows 8.1';
|
||||
$detail['type'] = 'Windows PC';
|
||||
} elseif (strpos($uaLower, 'windows nt 6.1') !== false) {
|
||||
$detail['os'] = 'Windows 7';
|
||||
$detail['type'] = 'Windows PC';
|
||||
} elseif (strpos($uaLower, 'windows') !== false) {
|
||||
$detail['os'] = 'Windows';
|
||||
$detail['type'] = 'Windows PC';
|
||||
} elseif (strpos($uaLower, 'macintosh') !== false || strpos($uaLower, 'mac os x') !== false) {
|
||||
// Extract macOS version
|
||||
if (preg_match('/mac os x (\d+[\._]\d+[\._]?\d*)/i', $ua, $m)) {
|
||||
$ver = str_replace('_', '.', $m[1]);
|
||||
$detail['os'] = 'macOS ' . $ver;
|
||||
} else {
|
||||
$detail['os'] = 'macOS';
|
||||
}
|
||||
$detail['type'] = 'Mac';
|
||||
$detail['brand'] = 'Apple';
|
||||
} elseif (strpos($uaLower, 'iphone') !== false) {
|
||||
$detail['type'] = 'iPhone';
|
||||
$detail['brand'] = 'Apple';
|
||||
if (preg_match('/iphone os (\d+[\._]\d+)/i', $ua, $m)) {
|
||||
$detail['os'] = 'iOS ' . str_replace('_', '.', $m[1]);
|
||||
} else {
|
||||
$detail['os'] = 'iOS';
|
||||
}
|
||||
} elseif (strpos($uaLower, 'ipad') !== false) {
|
||||
$detail['type'] = 'iPad';
|
||||
$detail['brand'] = 'Apple';
|
||||
if (preg_match('/cpu os (\d+[\._]\d+)/i', $ua, $m)) {
|
||||
$detail['os'] = 'iPadOS ' . str_replace('_', '.', $m[1]);
|
||||
} else {
|
||||
$detail['os'] = 'iPadOS';
|
||||
}
|
||||
} elseif (strpos($uaLower, 'android') !== false) {
|
||||
// Extract Android version
|
||||
$androidVer = '';
|
||||
if (preg_match('/android (\d+[\.\d]*)/i', $ua, $m)) {
|
||||
$androidVer = $m[1];
|
||||
}
|
||||
$detail['os'] = 'Android' . ($androidVer ? ' ' . $androidVer : '');
|
||||
|
||||
// Try to detect brand/model from user agent
|
||||
// Common pattern: Android X.X; BRAND MODEL Build/...
|
||||
$detail['brand'] = '';
|
||||
if (preg_match('/;\s*([^;)]+)\s*Build\//i', $ua, $m)) {
|
||||
$model = trim($m[1]);
|
||||
// Clean up common prefixes
|
||||
$brandMap = [
|
||||
'SM-' => 'Samsung', 'GT-' => 'Samsung', 'SAMSUNG' => 'Samsung',
|
||||
'Redmi' => 'Xiaomi', 'Mi ' => 'Xiaomi', 'POCO' => 'Xiaomi POCO', 'M2' => 'Xiaomi',
|
||||
'vivo' => 'Vivo', 'V2' => 'Vivo',
|
||||
'OPPO' => 'OPPO', 'CPH' => 'OPPO', 'RMX' => 'Realme',
|
||||
'Pixel' => 'Google Pixel', 'Nexus' => 'Google Nexus',
|
||||
'HUAWEI' => 'Huawei', 'VOG' => 'Huawei', 'ELE' => 'Huawei',
|
||||
'ASUS' => 'ASUS', 'Nokia' => 'Nokia', 'LG' => 'LG',
|
||||
'Sony' => 'Sony', 'OnePlus' => 'OnePlus', 'IN2' => 'OnePlus',
|
||||
'Infinix' => 'Infinix', 'TECNO' => 'Tecno', 'itel' => 'Itel',
|
||||
];
|
||||
$matched = false;
|
||||
foreach ($brandMap as $prefix => $brand) {
|
||||
if (stripos($model, $prefix) !== false) {
|
||||
$detail['brand'] = $brand;
|
||||
$detail['type'] = $model;
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
$detail['type'] = $model;
|
||||
}
|
||||
} else {
|
||||
$detail['type'] = 'Android Device';
|
||||
}
|
||||
} elseif (strpos($uaLower, 'linux') !== false) {
|
||||
$detail['os'] = 'Linux';
|
||||
$detail['type'] = 'Linux PC';
|
||||
}
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
function getBrowserName($ua) {
|
||||
// Order matters: check Edge before Chrome, check Chrome before Safari
|
||||
if (preg_match('/Edg[e\/]?\s*(\d+[\.\d]*)/i', $ua, $m)) return 'Edge ' . intval($m[1]);
|
||||
if (preg_match('/OPR\/(\d+[\.\d]*)/i', $ua, $m)) return 'Opera ' . intval($m[1]);
|
||||
if (preg_match('/Chrome\/(\d+[\.\d]*)/i', $ua, $m)) return 'Chrome ' . intval($m[1]);
|
||||
if (preg_match('/Firefox\/(\d+[\.\d]*)/i', $ua, $m)) return 'Firefox ' . intval($m[1]);
|
||||
if (preg_match('/Version\/(\d+[\.\d]*).*Safari/i', $ua, $m)) return 'Safari ' . intval($m[1]);
|
||||
if (strpos($ua, 'Safari') !== false) return 'Safari';
|
||||
return 'Unknown Browser';
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT * FROM login_history WHERE id_account = ? ORDER BY login_time DESC");
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$result = [];
|
||||
foreach ($logs as $log) {
|
||||
$time = strtotime($log['login_time']);
|
||||
$device = getDeviceDetail($log['device_info']);
|
||||
$browser = getBrowserName($log['device_info']);
|
||||
|
||||
// Build display name
|
||||
$deviceName = $device['type'];
|
||||
if ($device['brand'] && stripos($device['type'], $device['brand']) === false) {
|
||||
$deviceName = $device['brand'] . ' ' . $device['type'];
|
||||
}
|
||||
|
||||
$isDesktop = in_array($device['type'], ['Windows PC', 'Mac', 'Linux PC']) ||
|
||||
strpos(strtolower($device['os']), 'windows') !== false ||
|
||||
strpos(strtolower($device['os']), 'macos') !== false ||
|
||||
strpos(strtolower($device['os']), 'linux') !== false;
|
||||
|
||||
$result[] = [
|
||||
'date' => date('d F Y', $time),
|
||||
'time' => date('H:i:s', $time),
|
||||
'device' => $deviceName,
|
||||
'os' => $device['os'],
|
||||
'browser' => $browser,
|
||||
'ip' => $log['ip_address'],
|
||||
'is_desktop' => $isDesktop
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'logs' => $result]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['success' => false, 'message' => 'Database error']);
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
/**
|
||||
* API Monitoring - Backend for Arduino ESP32 Component Monitoring
|
||||
* Endpoints:
|
||||
* GET ?action=status → Get all component statuses with extra data
|
||||
* POST ?action=update → Update a component from Arduino/ESP32
|
||||
* GET ?action=history&id=X → Get history for a specific component
|
||||
* POST ?action=bulk_update → Update multiple components at once
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
include 'koneksi.php';
|
||||
|
||||
// ─── Create tables ──────────────────────────────────────────────────────────
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS monitoring_components (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
component_key VARCHAR(50) UNIQUE NOT NULL,
|
||||
component_name VARCHAR(100) NOT NULL,
|
||||
component_type VARCHAR(50) NOT NULL DEFAULT 'sensor',
|
||||
status ENUM('online','offline','warning','error') DEFAULT 'offline',
|
||||
value VARCHAR(100) DEFAULT NULL,
|
||||
unit VARCHAR(20) DEFAULT NULL,
|
||||
icon VARCHAR(50) DEFAULT 'mdi-chip',
|
||||
color VARCHAR(20) DEFAULT '#4B49AC',
|
||||
image VARCHAR(100) DEFAULT NULL,
|
||||
extra_data JSON DEFAULT NULL,
|
||||
last_seen DATETIME DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
");
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS monitoring_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
component_id INT NOT NULL,
|
||||
value VARCHAR(100) DEFAULT NULL,
|
||||
status ENUM('online','offline','warning','error') DEFAULT 'online',
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (component_id) REFERENCES monitoring_components(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
");
|
||||
|
||||
// ─── Ensure columns exist ───────────────────────────────────────────────────
|
||||
try { $pdo->query("SELECT extra_data FROM monitoring_components LIMIT 1"); } catch (Exception $e) {
|
||||
$pdo->exec("ALTER TABLE monitoring_components ADD COLUMN extra_data JSON DEFAULT NULL");
|
||||
}
|
||||
try { $pdo->query("SELECT image FROM monitoring_components LIMIT 1"); } catch (Exception $e) {
|
||||
$pdo->exec("ALTER TABLE monitoring_components ADD COLUMN image VARCHAR(100) DEFAULT NULL");
|
||||
}
|
||||
|
||||
// ─── Seed defaults ──────────────────────────────────────────────────────────
|
||||
$count = $pdo->query("SELECT COUNT(*) FROM monitoring_components")->fetchColumn();
|
||||
if ($count == 0) {
|
||||
$defaults = [
|
||||
['esp32', 'Mikrokontroler ESP32', 'controller', 'offline', null, null,
|
||||
'mdi-developer-board', '#4B49AC', 'esp32.jpeg',
|
||||
json_encode(['wifi_status'=>'off','wifi_rssi'=>'0','wifi_ssid'=>'','wifi_ip'=>'',
|
||||
'bluetooth'=>'off','cpu_freq'=>'240','free_heap'=>'0',
|
||||
'uptime'=>'0','temperature'=>'0','voltage_in'=>'0'])],
|
||||
|
||||
['fingerprint', 'Sensor Sidik Jari AS608', 'sensor', 'offline', null, null,
|
||||
'mdi-fingerprint', '#F3797E', 'as608.jpeg',
|
||||
json_encode(['sensor_state'=>'ready','enrolled_count'=>'0',
|
||||
'last_scan_result'=>'none','confidence'=>'0','last_matched_id'=>''])],
|
||||
|
||||
['tft_screen', 'Layar TFT Touch Screen ILI9488', 'display', 'offline', null, null,
|
||||
'mdi-monitor', '#7DA0FA', 'ili9488.jpg',
|
||||
json_encode(['display_state'=>'active','current_page'=>'Halaman Standby',
|
||||
'brightness'=>'100','touch_enabled'=>'yes','resolution'=>'480x320'])],
|
||||
|
||||
['relay', 'Modul Relay 5V 1 Channel', 'actuator', 'offline', null, null,
|
||||
'mdi-electric-switch', '#7978E9', 'relay.png',
|
||||
json_encode(['relay_state'=>'off','circuit_connected'=>'no','trigger_count'=>'0'])],
|
||||
|
||||
['solenoid_lock', 'Solenoid Door Lock NC 12V', 'actuator', 'offline', null, null,
|
||||
'mdi-lock', '#E74C3C', 'solenoid.jpg',
|
||||
json_encode(['lock_state'=>'locked','voltage'=>'0','power_watts'=>'0',
|
||||
'open_count'=>'0','last_opened'=>''])],
|
||||
|
||||
['buzzer', 'Buzzer', 'actuator', 'offline', null, null,
|
||||
'mdi-volume-high', '#4ECDC4', 'buzzer.jpeg',
|
||||
json_encode(['buzzer_state'=>'silent','last_tone'=>'none','beep_count'=>'0'])],
|
||||
|
||||
['led_red', 'LED Indikator Merah', 'indicator', 'offline', null, null,
|
||||
'mdi-led-on', '#FF6B6B', 'led_red.png',
|
||||
json_encode(['led_state'=>'off','blink_mode'=>'none','brightness'=>'0'])],
|
||||
|
||||
['led_green', 'LED Indikator Hijau', 'indicator', 'offline', null, null,
|
||||
'mdi-led-on', '#51CF66', 'led_green.png',
|
||||
json_encode(['led_state'=>'off','blink_mode'=>'none','brightness'=>'0'])],
|
||||
|
||||
['power_supply', 'Adaptor Power Supply 12V', 'power', 'offline', '0', 'V',
|
||||
'mdi-power-plug', '#FFA726', 'power_supply.png',
|
||||
json_encode(['voltage_in'=>'0','voltage_out'=>'0','current_out'=>'0',
|
||||
'power_watts'=>'0'])],
|
||||
|
||||
['step_down', 'Step Down 12V ke 5V LM2596', 'power', 'offline', '0', 'V',
|
||||
'mdi-arrow-down-bold', '#7DA0FA', 'step_down.png',
|
||||
json_encode(['state'=>'active','voltage_in'=>'0','voltage_out'=>'0',
|
||||
'current_out'=>'0','power_watts'=>'0','temperature'=>'0','efficiency'=>'0'])],
|
||||
|
||||
['battery', 'Baterai Li-ion 18650', 'power', 'offline', '0', '%',
|
||||
'mdi-battery', '#66BB6A', 'battery.png',
|
||||
json_encode(['bat_state'=>'standby','voltage'=>'0','charge_percent'=>'0',
|
||||
'current'=>'0','power_watts'=>'0','temperature'=>'0'])],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO monitoring_components
|
||||
(component_key,component_name,component_type,status,value,unit,icon,color,image,extra_data)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)");
|
||||
foreach ($defaults as $d) $stmt->execute($d);
|
||||
}
|
||||
|
||||
// ─── Router ─────────────────────────────────────────────────────────────────
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
|
||||
case 'status':
|
||||
$rows = $pdo->query("
|
||||
SELECT *, TIMESTAMPDIFF(SECOND, last_seen, NOW()) as seconds_ago
|
||||
FROM monitoring_components
|
||||
ORDER BY FIELD(component_type,'controller','sensor','display','actuator','indicator','power'), id
|
||||
")->fetchAll();
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
if ($r['last_seen'] && $r['seconds_ago'] > 30 && $r['status'] === 'online') {
|
||||
$pdo->prepare("UPDATE monitoring_components SET status='offline' WHERE id=?")->execute([$r['id']]);
|
||||
$r['status'] = 'offline';
|
||||
}
|
||||
if (isset($r['extra_data']) && is_string($r['extra_data']))
|
||||
$r['extra_data'] = json_decode($r['extra_data'], true);
|
||||
}
|
||||
|
||||
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
|
||||
$warning = count(array_filter($rows, fn($r) => $r['status'] === 'warning'));
|
||||
$error = count(array_filter($rows, fn($r) => $r['status'] === 'error'));
|
||||
$offline = count(array_filter($rows, fn($r) => $r['status'] === 'offline'));
|
||||
|
||||
echo json_encode([
|
||||
'success' => true, 'data' => $rows,
|
||||
'summary' => ['total'=>count($rows),'online'=>$online,'warning'=>$warning,'error'=>$error,'offline'=>$offline]
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$key = $input['key'] ?? '';
|
||||
$status = $input['status'] ?? 'online';
|
||||
$value = $input['value'] ?? null;
|
||||
$extra = $input['extra'] ?? null;
|
||||
|
||||
if (!$key) { echo json_encode(['success'=>false,'error'=>'Missing key']); break; }
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM monitoring_components WHERE component_key=?");
|
||||
$stmt->execute([$key]);
|
||||
$comp = $stmt->fetch();
|
||||
if (!$comp) { echo json_encode(['success'=>false,'error'=>'Not found: '.$key]); break; }
|
||||
|
||||
$cur = json_decode($comp['extra_data'] ?? '{}', true) ?: [];
|
||||
if ($extra && is_array($extra)) $cur = array_merge($cur, $extra);
|
||||
|
||||
$pdo->prepare("UPDATE monitoring_components SET status=?, value=?, extra_data=?, last_seen=NOW() WHERE component_key=?")
|
||||
->execute([$status, $value, json_encode($cur), $key]);
|
||||
|
||||
$pdo->prepare("INSERT INTO monitoring_history (component_id, value, status) VALUES (?,?,?)")
|
||||
->execute([$comp['id'], $value, $status]);
|
||||
|
||||
echo json_encode(['success'=>true,'message'=>'Updated '.$key]);
|
||||
break;
|
||||
|
||||
case 'history':
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$limit = (int)($_GET['limit'] ?? 50);
|
||||
$stmt = $pdo->prepare("SELECT h.*, c.component_name, c.unit FROM monitoring_history h
|
||||
JOIN monitoring_components c ON c.id=h.component_id WHERE h.component_id=?
|
||||
ORDER BY h.recorded_at DESC LIMIT ?");
|
||||
$stmt->execute([$id, $limit]);
|
||||
echo json_encode(['success'=>true,'data'=>$stmt->fetchAll()]);
|
||||
break;
|
||||
|
||||
case 'bulk_update':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$components = $input['components'] ?? [];
|
||||
$updated = 0;
|
||||
foreach ($components as $c) {
|
||||
$k = $c['key'] ?? ''; if (!$k) continue;
|
||||
$s = $c['status'] ?? 'online'; $v = $c['value'] ?? null; $e = $c['extra'] ?? null;
|
||||
$row = $pdo->prepare("SELECT extra_data FROM monitoring_components WHERE component_key=?");
|
||||
$row->execute([$k]); $r = $row->fetch();
|
||||
$cur = json_decode($r['extra_data'] ?? '{}', true) ?: [];
|
||||
if ($e && is_array($e)) $cur = array_merge($cur, $e);
|
||||
$pdo->prepare("UPDATE monitoring_components SET status=?, value=?, extra_data=?, last_seen=NOW() WHERE component_key=?")
|
||||
->execute([$s, $v, json_encode($cur), $k]);
|
||||
$updated++;
|
||||
}
|
||||
echo json_encode(['success'=>true,'updated'=>$updated]);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['success'=>false,'error'=>'Invalid action',
|
||||
'actions'=>['status','update','bulk_update','history']]);
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$id = $_REQUEST['id'] ?? 0;
|
||||
|
||||
if ($action === 'check_new') {
|
||||
$stmt = $pdo->prepare("SELECT id, title, message, icon FROM notifications WHERE is_read = 0 AND is_deleted = 0 ORDER BY time DESC LIMIT 1");
|
||||
$stmt->execute();
|
||||
$notif = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['status' => 'success', 'data' => $notif ?: null]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'mark_read') {
|
||||
$stmt = $pdo->prepare("UPDATE notifications SET is_read = 1 WHERE id = ?");
|
||||
$result = $stmt->execute([$id]);
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'mark_all_read') {
|
||||
$stmt = $pdo->prepare("UPDATE notifications SET is_read = 1 WHERE is_deleted = 0");
|
||||
$result = $stmt->execute();
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete_all_to_trash') {
|
||||
$stmt = $pdo->prepare("UPDATE notifications SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP WHERE is_deleted = 0");
|
||||
$result = $stmt->execute();
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'move_trash') {
|
||||
$stmt = $pdo->prepare("UPDATE notifications SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$result = $stmt->execute([$id]);
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'restore') {
|
||||
$stmt = $pdo->prepare("UPDATE notifications SET is_deleted = 0, deleted_at = NULL WHERE id = ?");
|
||||
$result = $stmt->execute([$id]);
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete_permanently') {
|
||||
$stmt = $pdo->prepare("DELETE FROM notifications WHERE id = ?");
|
||||
$result = $stmt->execute([$id]);
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'empty_trash') {
|
||||
$stmt = $pdo->prepare("DELETE FROM notifications WHERE is_deleted = 1");
|
||||
$result = $stmt->execute();
|
||||
echo json_encode(['status' => $result ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'create_notification') {
|
||||
include 'notification_helper.php';
|
||||
$icon = $_POST['icon'] ?? 'mdi-information';
|
||||
$title = $_POST['title'] ?? 'Info';
|
||||
$message = $_POST['message'] ?? '';
|
||||
$detail = $_POST['detail'] ?? '';
|
||||
$is_danger = isset($_POST['is_danger']) && $_POST['is_danger'] == '1' ? true : false;
|
||||
|
||||
$res = send_notification($pdo, $icon, $title, $message, $detail, $is_danger);
|
||||
echo json_encode(['status' => $res ? 'success' : 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_warnings') {
|
||||
$excludeCond = "AND title NOT LIKE '%baterai%' AND title NOT LIKE '%battery%' AND title NOT LIKE '%pesan%' AND title NOT LIKE '%message%' AND title NOT LIKE '%chat%'";
|
||||
|
||||
// Fetch top 5 recent UNREAD and DANGER notifications
|
||||
$stmt = $pdo->prepare("SELECT id, icon, title, message, time FROM notifications WHERE is_deleted = 0 AND is_danger = 1 AND is_read = 0 $excludeCond ORDER BY time DESC LIMIT 5");
|
||||
$stmt->execute();
|
||||
$warnings = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Also get total unread count of ALL notifications
|
||||
$countStmt = $pdo->query("SELECT COUNT(*) FROM notifications WHERE is_deleted = 0 AND is_read = 0 $excludeCond");
|
||||
$total_unread = $countStmt->fetchColumn();
|
||||
|
||||
// Also get total unread danger count
|
||||
$dangerCountStmt = $pdo->query("SELECT COUNT(*) FROM notifications WHERE is_deleted = 0 AND is_danger = 1 AND is_read = 0 $excludeCond");
|
||||
$danger_unread = $dangerCountStmt->fetchColumn();
|
||||
|
||||
echo json_encode(['status' => 'success', 'warnings' => $warnings, 'total_unread' => $total_unread, 'danger_unread' => $danger_unread]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
|
||||
?>
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
include 'run_background.php';
|
||||
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$website = "https://api.telegram.org/bot" . $botToken;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
$chatId = $_POST['chat_id'] ?? '';
|
||||
|
||||
if (!$action || !$chatId) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get occupant id
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE telegram_id = ?");
|
||||
$stmt->execute([$chatId]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$occupant) {
|
||||
echo json_encode(['success' => false, 'error' => 'Occupant not found']);
|
||||
exit;
|
||||
}
|
||||
$id_occupant = $occupant['id_occupant'];
|
||||
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http";
|
||||
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
||||
$uri = rtrim(dirname($_SERVER['PHP_SELF']), '/\\');
|
||||
$baseUrl = $protocol . "://" . $host . $uri;
|
||||
|
||||
// Remove the Manage message
|
||||
$manPathMng = __DIR__ . '/manifest_manage.json';
|
||||
if (file_exists($manPathMng)) {
|
||||
$mng = json_decode(file_get_contents($manPathMng), true) ?: [];
|
||||
if (isset($mng[$chatId])) {
|
||||
$msgData = $mng[$chatId];
|
||||
if (is_array($msgData)) {
|
||||
$msgId = $msgData['msg_id'];
|
||||
$userMsgId = $msgData['user_msg_id'] ?? null;
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id=$msgId");
|
||||
if ($userMsgId) {
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id=$userMsgId");
|
||||
}
|
||||
} else {
|
||||
$msgId = $msgData;
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id=$msgId");
|
||||
}
|
||||
unset($mng[$chatId]);
|
||||
file_put_contents($manPathMng, json_encode($mng));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean old tokens
|
||||
$manifestsToClean = ['manifest_pin_tokens.json', 'manifest_edit_tokens.json'];
|
||||
foreach ($manifestsToClean as $mFile) {
|
||||
$mPath = __DIR__ . '/' . $mFile;
|
||||
if (file_exists($mPath)) {
|
||||
$mTkns = json_decode(file_get_contents($mPath), true) ?: [];
|
||||
$mChanged = false;
|
||||
foreach ($mTkns as $oldTk => $oldData) {
|
||||
if (isset($oldData['id_occupant']) && $oldData['id_occupant'] == $id_occupant) {
|
||||
if (isset($oldData['msg_id'])) {
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id={$oldData['msg_id']}");
|
||||
}
|
||||
if (isset($oldData['user_msg_id']) && $oldData['user_msg_id']) {
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id={$oldData['user_msg_id']}");
|
||||
}
|
||||
unset($mTkns[$oldTk]);
|
||||
$mChanged = true;
|
||||
}
|
||||
}
|
||||
if ($mChanged) {
|
||||
file_put_contents($mPath, json_encode($mTkns, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle action
|
||||
if ($action === 'forgot_pin') {
|
||||
// Pre-check for photo
|
||||
$checkStmt = $pdo->prepare("SELECT foto FROM occupant WHERE id_occupant = ?");
|
||||
$checkStmt->execute([$id_occupant]);
|
||||
$chk = $checkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$chk || empty($chk['foto'])) {
|
||||
$text = "⚠️ <b>Akses Ditolak</b>\n\nMaaf, Anda belum memiliki Data Foto Wajah yang terdaftar di sistem.\n\nSilakan gunakan menu <b>EDIT DATA</b> terlebih dahulu untuk mengunggah Foto Wajah Anda sebelum menggunakan fitur Verifikasi Biometrik.";
|
||||
file_get_contents($website . "/sendMessage?" . http_build_query(['chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'HTML']));
|
||||
echo json_encode(['success' => false, 'error' => 'No photo']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$manPath = __DIR__ . '/manifest_pin_tokens.json';
|
||||
$tkns = file_exists($manPath) ? (json_decode(file_get_contents($manPath), true) ?: []) : [];
|
||||
$tkns[$token] = ['id_occupant' => $id_occupant, 'created_at' => time(), 'expires_at' => time() + 300, 'user_msg_id' => 0];
|
||||
|
||||
file_put_contents($manPath, json_encode($tkns, JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode(['success' => true, 'redirect_url' => 'verification.php?token=' . $token]);
|
||||
exit;
|
||||
} elseif ($action === 'edit_data') {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$manPath = __DIR__ . '/manifest_edit_tokens.json';
|
||||
$tkns = file_exists($manPath) ? (json_decode(file_get_contents($manPath), true) ?: []) : [];
|
||||
$tkns[$token] = ['id_occupant' => $id_occupant, 'created_at' => time(), 'expires_at' => time() + 300, 'user_msg_id' => 0];
|
||||
|
||||
file_put_contents($manPath, json_encode($tkns, JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode(['success' => true, 'redirect_url' => 'edit_data.php?token=' . $token]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => false, 'error' => 'Unknown action']);
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
<?php
|
||||
/**
|
||||
* API Sync for TFT Design Editor
|
||||
* Handles save, load, list, delete, latest, and export_cpp actions
|
||||
*/
|
||||
require_once 'koneksi.php';
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
|
||||
// ========== SAVE / UPDATE DESIGN ==========
|
||||
case 'save':
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? 'Untitled');
|
||||
$json = $_POST['design_json'] ?? '';
|
||||
|
||||
if (empty($json)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'design_json is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hash = md5($json);
|
||||
|
||||
if ($id > 0) {
|
||||
// Update existing
|
||||
$stmt = $pdo->prepare("UPDATE tft_designs SET name = ?, design_json = ?, version_hash = ? WHERE id = ?");
|
||||
$stmt->execute([$name, $json, $hash, $id]);
|
||||
echo json_encode(['status' => 'success', 'message' => 'Design updated', 'id' => $id, 'version_hash' => $hash]);
|
||||
} else {
|
||||
// Insert new
|
||||
$stmt = $pdo->prepare("INSERT INTO tft_designs (name, design_json, version_hash) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$name, $json, $hash]);
|
||||
$newId = $pdo->lastInsertId();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Design saved', 'id' => intval($newId), 'version_hash' => $hash]);
|
||||
}
|
||||
break;
|
||||
|
||||
// ========== LOAD DESIGN BY ID ==========
|
||||
case 'load':
|
||||
$id = intval($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid ID']);
|
||||
exit;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT * FROM tft_designs WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
echo json_encode(['status' => 'success', 'data' => $row]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Design not found']);
|
||||
}
|
||||
break;
|
||||
|
||||
// ========== LIST ALL DESIGNS ==========
|
||||
case 'list':
|
||||
$stmt = $pdo->query("SELECT id, name, version_hash, created_at, updated_at FROM tft_designs ORDER BY updated_at DESC");
|
||||
$rows = $stmt->fetchAll();
|
||||
echo json_encode(['status' => 'success', 'data' => $rows]);
|
||||
break;
|
||||
|
||||
// ========== DELETE DESIGN ==========
|
||||
case 'delete':
|
||||
$id = intval($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid ID']);
|
||||
exit;
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM tft_designs WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['status' => 'success', 'message' => 'Design deleted']);
|
||||
break;
|
||||
|
||||
// ========== LATEST DESIGN (for ESP32 polling) ==========
|
||||
case 'latest':
|
||||
$stmt = $pdo->query("SELECT id, name, design_json, version_hash, updated_at FROM tft_designs ORDER BY updated_at DESC LIMIT 1");
|
||||
$row = $stmt->fetch();
|
||||
if ($row) {
|
||||
// Parse the design_json to get the active screen elements
|
||||
$designData = json_decode($row['design_json'], true);
|
||||
$activeScreen = $_GET['screen'] ?? '0';
|
||||
$elements = [];
|
||||
if (isset($designData['screens'][$activeScreen]['objects'])) {
|
||||
$elements = $designData['screens'][$activeScreen]['objects'];
|
||||
} elseif (isset($designData['screens']) && count($designData['screens']) > 0) {
|
||||
$firstScreen = reset($designData['screens']);
|
||||
$elements = $firstScreen['objects'] ?? [];
|
||||
}
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'version_hash' => $row['version_hash'],
|
||||
'screen_count' => isset($designData['screens']) ? count($designData['screens']) : 0,
|
||||
'elements' => $elements
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'empty', 'message' => 'No designs found']);
|
||||
}
|
||||
break;
|
||||
|
||||
// ========== EXPORT C++ CODE (Component-Based) ==========
|
||||
case 'export_cpp':
|
||||
$json = $_POST['design_json'] ?? '';
|
||||
if (empty($json)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'design_json is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$designData = json_decode($json, true);
|
||||
if (!$designData || !isset($designData['screens'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid JSON structure']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$orientation = $designData['orientation'] ?? 'landscape';
|
||||
$rotation = $orientation === 'landscape' ? 1 : 0;
|
||||
|
||||
$cpp = "// Auto-generated TFT UI Code (Component-Based)\n";
|
||||
$cpp .= "// Generated: " . date('Y-m-d H:i:s') . "\n";
|
||||
$cpp .= "// Library: TFT_eSPI\n\n";
|
||||
$cpp .= "#include <TFT_eSPI.h>\n";
|
||||
$cpp .= "TFT_eSPI tft = TFT_eSPI();\n\n";
|
||||
|
||||
$cpp .= "uint16_t hexToRGB565(uint32_t hex) {\n";
|
||||
$cpp .= " uint8_t r = (hex >> 16) & 0xFF;\n";
|
||||
$cpp .= " uint8_t g = (hex >> 8) & 0xFF;\n";
|
||||
$cpp .= " uint8_t b = hex & 0xFF;\n";
|
||||
$cpp .= " return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);\n";
|
||||
$cpp .= "}\n\n";
|
||||
|
||||
// Recursive function to generate C++ for components
|
||||
function generateCompCpp($comp, $offX = 0, $offY = 0) {
|
||||
$code = '';
|
||||
$type = $comp['type'] ?? '';
|
||||
$x = intval($comp['x'] ?? 0) + $offX;
|
||||
$y = intval($comp['y'] ?? 0) + $offY;
|
||||
$w = intval($comp['w'] ?? 0);
|
||||
$h = intval($comp['h'] ?? 0);
|
||||
$bg = str_replace('#', '0x', $comp['bg'] ?? '#1e2223');
|
||||
$r = intval($comp['radius'] ?? 0);
|
||||
$border = intval($comp['border'] ?? 0);
|
||||
$borderColor = str_replace('#', '0x', $comp['borderColor'] ?? '#444444');
|
||||
$label = $comp['label'] ?? $type;
|
||||
|
||||
$code .= " // [{$label}] {$type}\n";
|
||||
|
||||
// Draw background (skip transparent)
|
||||
if (($comp['bg'] ?? '') !== 'transparent' && ($comp['bg'] ?? '') !== '') {
|
||||
if ($r > 0) {
|
||||
$code .= " tft.fillRoundRect({$x}, {$y}, {$w}, {$h}, {$r}, hexToRGB565({$bg}));\n";
|
||||
} else {
|
||||
$code .= " tft.fillRect({$x}, {$y}, {$w}, {$h}, hexToRGB565({$bg}));\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Draw border
|
||||
if ($border > 0) {
|
||||
if ($r > 0) {
|
||||
$code .= " tft.drawRoundRect({$x}, {$y}, {$w}, {$h}, {$r}, hexToRGB565({$borderColor}));\n";
|
||||
} else {
|
||||
$code .= " tft.drawRect({$x}, {$y}, {$w}, {$h}, hexToRGB565({$borderColor}));\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific rendering
|
||||
switch ($type) {
|
||||
case 'button':
|
||||
case 'label':
|
||||
case 'datavalue':
|
||||
if (!empty($comp['text'])) {
|
||||
$text = addcslashes($comp['text'], '"\\');
|
||||
$txtColor = str_replace('#', '0x', $comp['textColor'] ?? '#FFFFFF');
|
||||
$fontSize = intval($comp['fontSize'] ?? 12);
|
||||
$tftSize = max(1, intval($fontSize / 8));
|
||||
$align = $comp['textAlign'] ?? 'center';
|
||||
$code .= " tft.setTextSize({$tftSize});\n";
|
||||
$code .= " tft.setTextColor(hexToRGB565({$txtColor}));\n";
|
||||
if ($align === 'center') {
|
||||
$code .= " tft.setTextDatum(MC_DATUM);\n";
|
||||
$tx = $x + intval($w / 2);
|
||||
$ty = $y + intval($h / 2);
|
||||
} elseif ($align === 'right') {
|
||||
$code .= " tft.setTextDatum(MR_DATUM);\n";
|
||||
$tx = $x + $w - 4;
|
||||
$ty = $y + intval($h / 2);
|
||||
} else {
|
||||
$code .= " tft.setTextDatum(ML_DATUM);\n";
|
||||
$tx = $x + 4;
|
||||
$ty = $y + intval($h / 2);
|
||||
}
|
||||
$code .= " tft.drawString(\"{$text}\", {$tx}, {$ty});\n";
|
||||
$code .= " tft.setTextDatum(TL_DATUM);\n";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'icon':
|
||||
if (!empty($comp['icon'])) {
|
||||
$icon = addcslashes($comp['icon'], '"\\');
|
||||
$txtColor = str_replace('#', '0x', $comp['textColor'] ?? '#FFD700');
|
||||
$fontSize = intval($comp['fontSize'] ?? 18);
|
||||
$tftSize = max(1, intval($fontSize / 8));
|
||||
$code .= " tft.setTextSize({$tftSize});\n";
|
||||
$code .= " tft.setTextColor(hexToRGB565({$txtColor}));\n";
|
||||
$code .= " tft.setTextDatum(MC_DATUM);\n";
|
||||
$code .= " tft.drawString(\"{$icon}\", " . ($x + intval($w/2)) . ", " . ($y + intval($h/2)) . ");\n";
|
||||
$code .= " tft.setTextDatum(TL_DATUM);\n";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'toggle':
|
||||
$on = ($comp['toggleState'] ?? true) ? 'true' : 'false';
|
||||
$knobR = intval($h / 2) - 2;
|
||||
$knobX = ($comp['toggleState'] ?? true) ? ($x + $w - intval($h/2)) : ($x + intval($h/2));
|
||||
$code .= " tft.fillCircle({$knobX}, " . ($y + intval($h/2)) . ", {$knobR}, hexToRGB565(0xFFFFFF));\n";
|
||||
break;
|
||||
|
||||
case 'progressbar':
|
||||
$pct = intval($comp['value'] ?? 60);
|
||||
$pw = intval($w * $pct / 100);
|
||||
$barColor = str_replace('#', '0x', $comp['barColor'] ?? '#4B49AC');
|
||||
$code .= " tft.fillRoundRect({$x}, {$y}, {$pw}, {$h}, {$r}, hexToRGB565({$barColor}));\n";
|
||||
break;
|
||||
}
|
||||
|
||||
// Recursively render children
|
||||
if (!empty($comp['children'])) {
|
||||
foreach ($comp['children'] as $child) {
|
||||
$code .= generateCompCpp($child, $x, $y);
|
||||
}
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
foreach ($designData['screens'] as $idx => $screen) {
|
||||
$screenName = preg_replace('/[^a-zA-Z0-9_]/', '_', $screen['name'] ?? 'Screen_' . $idx);
|
||||
$cpp .= "void draw_{$screenName}() {\n";
|
||||
$cpp .= " tft.fillScreen(TFT_BLACK);\n\n";
|
||||
|
||||
if (isset($screen['components'])) {
|
||||
foreach ($screen['components'] as $comp) {
|
||||
$cpp .= generateCompCpp($comp);
|
||||
$cpp .= "\n";
|
||||
}
|
||||
}
|
||||
$cpp .= "}\n\n";
|
||||
}
|
||||
|
||||
$cpp .= "void setup() {\n";
|
||||
$cpp .= " tft.init();\n";
|
||||
$cpp .= " tft.setRotation({$rotation});\n";
|
||||
$cpp .= " draw_" . preg_replace('/[^a-zA-Z0-9_]/', '_', $designData['screens'][0]['name'] ?? 'Screen_0') . "();\n";
|
||||
$cpp .= "}\n\n";
|
||||
$cpp .= "void loop() {\n";
|
||||
$cpp .= " // Touch or interaction handling here\n";
|
||||
$cpp .= "}\n";
|
||||
|
||||
echo json_encode(['status' => 'success', 'code' => $cpp]);
|
||||
break;
|
||||
|
||||
// ========== PUSH STATE FROM ESP32 ==========
|
||||
case 'push':
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true);
|
||||
if (!$data) {
|
||||
// Try POST fallback
|
||||
$data = json_decode($_POST['design_json'] ?? '', true);
|
||||
}
|
||||
if (!$data || !isset($data['screens'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid JSON. Must contain screens array.']);
|
||||
exit;
|
||||
}
|
||||
$name = $data['projectName'] ?? 'ESP32 Design';
|
||||
$json = json_encode($data);
|
||||
$hash = md5($json);
|
||||
|
||||
// Check if there is an existing design to update
|
||||
$existing = $pdo->query("SELECT id FROM tft_designs ORDER BY updated_at DESC LIMIT 1")->fetch();
|
||||
if ($existing) {
|
||||
$stmt = $pdo->prepare("UPDATE tft_designs SET name = ?, design_json = ?, version_hash = ? WHERE id = ?");
|
||||
$stmt->execute([$name, $json, $hash, $existing['id']]);
|
||||
echo json_encode(['status' => 'success', 'message' => 'Design updated from ESP32', 'id' => $existing['id'], 'version_hash' => $hash]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("INSERT INTO tft_designs (name, design_json, version_hash) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$name, $json, $hash]);
|
||||
$newId = $pdo->lastInsertId();
|
||||
echo json_encode(['status' => 'success', 'message' => 'Design pushed from ESP32', 'id' => intval($newId), 'version_hash' => $hash]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unknown action: ' . $action]);
|
||||
break;
|
||||
}
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
<?php
|
||||
// Set header ke format JSON karena API ini selalu mengembalikan data JSON
|
||||
header('Content-Type: application/json');
|
||||
// File yang digunakan untuk menyimpan status dan perintah sementara ESP32
|
||||
$stateFile = 'esp32_state.json';
|
||||
// Inisialisasi file status dengan nilai bawaan jika file tersebut belum ada
|
||||
if (!file_exists($stateFile)) {
|
||||
$initialState = [
|
||||
"command" => "IDLE", // Perintah awal (Tidak ada perintah)
|
||||
"ssid" => "", // Nama WiFi untuk diubah
|
||||
"password" => "", // Password WiFi untuk diubah
|
||||
"scan_results" => [], // Tempat menyimpan hasil scan WiFi
|
||||
"status" => "offline", // Status koneksi ESP32
|
||||
"current_ssid" => "Unknown", // WiFi yang sedang terhubung ke ESP32
|
||||
"last_seen" => 0, // Waktu terakhir ESP32 mengirim data (timestamp)
|
||||
"esp_ip" => "Unknown", // Alamat IP dari ESP32
|
||||
"change_status" => "idle", // Status saat proses penggantian WiFi
|
||||
"rssi" => -90, // Kekuatan sinyal WiFi
|
||||
"tft_force_screen" => "none" // Perintah paksa pindah layar pada layar sentuh TFT
|
||||
];
|
||||
// Simpan nilai bawaan ke dalam file JSON
|
||||
file_put_contents($stateFile, json_encode($initialState));
|
||||
}
|
||||
// Baca isi file status dan ubah dari format JSON menjadi array PHP
|
||||
$state = json_decode(file_get_contents($stateFile), true);
|
||||
// Jika hasil bacaan bukan array (mungkin file rusak), atur ulang ke nilai bawaan
|
||||
if (!is_array($state)) {
|
||||
$state = [
|
||||
"command" => "IDLE",
|
||||
"ssid" => "",
|
||||
"password" => "",
|
||||
"scan_results" => [],
|
||||
"status" => "offline",
|
||||
"current_ssid" => "Unknown",
|
||||
"last_seen" => 0,
|
||||
"esp_ip" => "Unknown",
|
||||
"change_status" => "idle",
|
||||
"rssi" => -90,
|
||||
"tft_force_screen" => "none"
|
||||
];
|
||||
}
|
||||
// Ambil parameter 'action' dari URL (GET) atau form (POST)
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : (isset($_POST['action']) ? $_POST['action'] : '');
|
||||
// ==========================================
|
||||
// ENDPOINT UNTUK DASHBOARD WEB (DIPANGGIL VIA AJAX)
|
||||
// ==========================================
|
||||
// Aksi untuk menyuruh ESP32 memindai daftar WiFi di sekitarnya
|
||||
if ($action == 'trigger_scan') {
|
||||
$state['command'] = 'SCAN';
|
||||
$state['scan_results'] = []; // Hapus hasil scan sebelumnya agar data bersih
|
||||
file_put_contents($stateFile, json_encode($state)); // Simpan perintah ke file
|
||||
echo json_encode(["success" => true, "message" => "Scan triggered"]);
|
||||
exit;
|
||||
}
|
||||
// Aksi untuk menyalakan Bluetooth pada ESP32
|
||||
if ($action == 'trigger_bt_on') {
|
||||
$state['command'] = 'BT_ON';
|
||||
$state['bt_status'] = 'pending'; // Status menunggu ESP32 merespons
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode(["success" => true, "message" => "Bluetooth ON command queued"]);
|
||||
exit;
|
||||
}
|
||||
// Aksi untuk mematikan Bluetooth pada ESP32
|
||||
if ($action == 'trigger_bt_off') {
|
||||
$state['command'] = 'BT_OFF';
|
||||
$state['bt_status'] = 'off';
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode(["success" => true, "message" => "Bluetooth OFF command queued"]);
|
||||
exit;
|
||||
}
|
||||
// Mengambil status Bluetooth saat ini untuk ditampilkan di dashboard
|
||||
if ($action == 'get_bt_status') {
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"bt_status" => isset($state['bt_status']) ? $state['bt_status'] : 'off'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// Mengambil hasil scan WiFi yang sudah selesai diproses ESP32
|
||||
if ($action == 'get_scan_results') {
|
||||
// Jika ESP32 sudah selesai memindai
|
||||
if ($state['command'] == 'SCAN_COMPLETE') {
|
||||
echo json_encode(["success" => true, "results" => $state['scan_results']]);
|
||||
// Kembalikan perintah ke IDLE agar tidak mengulang scan
|
||||
$state['command'] = 'IDLE';
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
} else {
|
||||
// Jika masih dalam proses pemindaian
|
||||
echo json_encode(["success" => false, "message" => "Scanning in progress"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Aksi untuk memberikan instruksi ke ESP32 agar pindah/koneksi ke WiFi lain
|
||||
if ($action == 'change_wifi') {
|
||||
$new_ssid = isset($_POST['ssid']) ? $_POST['ssid'] : '';
|
||||
$new_pass = isset($_POST['password']) ? $_POST['password'] : '';
|
||||
// SSID tidak boleh kosong
|
||||
if (empty($new_ssid)) {
|
||||
echo json_encode(["success" => false, "message" => "SSID cannot be empty"]);
|
||||
exit;
|
||||
}
|
||||
// Set perintah ganti WiFi (CHANGE) beserta SSID dan Password baru
|
||||
$state['command'] = 'CHANGE';
|
||||
$state['ssid'] = $new_ssid;
|
||||
$state['password'] = $new_pass;
|
||||
$state['change_status'] = 'processing';
|
||||
$state['change_message'] = 'Menunggu ESP32 memproses pergantian WiFi...';
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode(["success" => true, "message" => "Change command queued"]);
|
||||
exit;
|
||||
}
|
||||
// Aksi untuk melihat status keseluruhan ESP32 dari dashboard
|
||||
if ($action == 'get_status') {
|
||||
// Hitung selisih waktu dari laporan terakhir ESP32 ke waktu sekarang
|
||||
$time_diff = time() - $state['last_seen'];
|
||||
// ESP32 dianggap online jika melapor dalam 10 detik terakhir
|
||||
$is_online = $time_diff < 10;
|
||||
$previousStatus = $state['status'];
|
||||
if ($time_diff <= 10) {
|
||||
$state['status'] = 'online';
|
||||
// Hapus tanda pemberitahuan offline jika sudah kembali online
|
||||
if (isset($state['wifi_notified_offline']) && $state['wifi_notified_offline']) {
|
||||
$state['wifi_notified_offline'] = false;
|
||||
}
|
||||
} else if ($time_diff <= 70) {
|
||||
// Toleransi waktu 60 detik tambahan dengan status 'connecting' sebelum dicap offline
|
||||
$state['status'] = 'connecting';
|
||||
} else {
|
||||
$state['status'] = 'offline';
|
||||
// Kirim notifikasi jika sebelumnya belum dikirim (agar tidak spam)
|
||||
if (empty($state['wifi_notified_offline'])) {
|
||||
$state['wifi_notified_offline'] = true;
|
||||
include_once 'koneksi.php';
|
||||
include_once 'notification_helper.php';
|
||||
require_once 'PushHelper.php';
|
||||
$ssidInfo = isset($state['current_ssid']) ? $state['current_ssid'] : 'Unknown';
|
||||
send_notification($pdo, 'mdi-wifi-off', 'Wi-Fi Disconnected', "The ESP32 microcontroller has disconnected from {$ssidInfo}.", "The system detected that the device went offline and is unresponsive. Last seen: " . date('d M Y H:i:s', $state['last_seen']) . ".", true);
|
||||
PushHelper::broadcastToAdmins($pdo, 'Wi-Fi Disconnected', "The ESP32 microcontroller has disconnected from {$ssidInfo}.", '/dashboard.php');
|
||||
}
|
||||
}
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
// Tampilkan data ke dashboard
|
||||
echo json_encode([
|
||||
"status" => $state['status'],
|
||||
"current_ssid" => $state['current_ssid'],
|
||||
"last_seen" => $state['last_seen'],
|
||||
"esp_ip" => isset($state['esp_ip']) ? $state['esp_ip'] : "Unknown",
|
||||
"rssi" => isset($state['rssi']) ? $state['rssi'] : -90
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// Mengecek apakah proses pergantian WiFi di ESP32 sudah berhasil, gagal, atau masih proses
|
||||
if ($action == 'check_change_status') {
|
||||
echo json_encode([
|
||||
"status" => isset($state['change_status']) ? $state['change_status'] : "idle",
|
||||
"message" => isset($state['change_message']) ? $state['change_message'] : "",
|
||||
"reason" => isset($state['change_reason']) ? $state['change_reason'] : ""
|
||||
]);
|
||||
// Jika proses ganti WiFi sudah mencapai akhir (berhasil/gagal), kembalikan ke status idle
|
||||
if (isset($state['change_status']) && ($state['change_status'] == 'success' || $state['change_status'] == 'failed')) {
|
||||
$state['change_status'] = 'idle';
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// ==========================================
|
||||
// ENDPOINT UNTUK ESP32 (MEMBACA/MENGIRIM DATA DARI HARDWARE)
|
||||
// ==========================================
|
||||
// Endpoint yang dipanggil terus-menerus oleh ESP32 untuk melapor dan mengambil instruksi
|
||||
if ($action == 'esp_poll') {
|
||||
// Tangkap data status terbaru dari ESP32
|
||||
$current_ssid = isset($_GET['ssid']) ? $_GET['ssid'] : 'Unknown';
|
||||
$esp_ip = isset($_GET['ip']) ? $_GET['ip'] : 'Unknown';
|
||||
$rssi = isset($_GET['rssi']) ? (int) $_GET['rssi'] : -90;
|
||||
$bt_on = isset($_GET['bt_on']) ? ($_GET['bt_on'] == '1') : false; // Status bluetooth aktual pada alat
|
||||
// Perbarui data terakhir ESP32 ke dalam file state
|
||||
$state['status'] = 'online';
|
||||
$state['current_ssid'] = $current_ssid;
|
||||
$state['esp_ip'] = $esp_ip;
|
||||
$state['rssi'] = $rssi;
|
||||
$state['last_seen'] = time(); // Catat waktu lapor agar tahu jika alat mati
|
||||
// Lacak status Bluetooth yang dilaporkan oleh hardware
|
||||
if ($bt_on) {
|
||||
$state['bt_status'] = 'active';
|
||||
} else if (isset($state['bt_status']) && $state['bt_status'] === 'active') {
|
||||
$state['bt_status'] = 'off'; // Tandai mati jika alat bilang mati
|
||||
}
|
||||
// Respons yang akan diberikan ke ESP32, termasuk perintah saat ini
|
||||
$response = [
|
||||
"command" => $state['command'],
|
||||
"ssid" => $state['ssid'],
|
||||
"password" => $state['password'],
|
||||
"tft_force_screen" => isset($state['tft_force_screen']) ? $state['tft_force_screen'] : "none"
|
||||
];
|
||||
// Sisipkan flag jika ada foto baru yang berhasil diambil oleh sistem web
|
||||
if (isset($state['photo_done']) && $state['photo_done']) {
|
||||
$response['photo_done'] = true;
|
||||
$state['photo_done'] = false; // Reset flag agar tidak dikirim lagi
|
||||
}
|
||||
// Sisipkan flag jika ada form laporan yang sudah disubmit
|
||||
if (isset($state['report_submitted']) && $state['report_submitted']) {
|
||||
$response['report_submitted'] = true;
|
||||
$state['report_submitted'] = false;
|
||||
}
|
||||
// Kirim kode pendaftaran (form code) ke ESP32 jika sedang ada proses registrasi
|
||||
if (isset($state['form_code'])) {
|
||||
$response['form_code'] = $state['form_code'];
|
||||
}
|
||||
// Baca status kunci pintu dari file teks
|
||||
$doorState = 'locked'; // Secara bawaan terkunci
|
||||
if (file_exists('door_status.txt')) {
|
||||
$doorState = trim(file_get_contents('door_status.txt'));
|
||||
}
|
||||
$response["remote_door_state"] = $doorState;
|
||||
// Pengecekan apakah ruangan dikunci secara otomatis berdasarkan jadwal di database
|
||||
include_once 'koneksi.php';
|
||||
date_default_timezone_set('Asia/Jakarta');
|
||||
$currentDay = date('l');
|
||||
$currentTime = date('H:i:s');
|
||||
try {
|
||||
// Cek apakah ada jadwal pembatasan (room restriction) di waktu ini
|
||||
$stmtRestrict = $pdo->prepare("SELECT COUNT(*) FROM room_restrictions WHERE (day_of_week = ? OR day_of_week = 'Every Day') AND ? BETWEEN time_from AND time_to");
|
||||
$stmtRestrict->execute([$currentDay, $currentTime]);
|
||||
$response["room_locked"] = $stmtRestrict->fetchColumn() > 0;
|
||||
} catch (Exception $e) {
|
||||
$response["room_locked"] = false;
|
||||
}
|
||||
// Ambil daftar PIN yang sah dan aktif dari penghuni yang berhak
|
||||
$valid_pins = [];
|
||||
$active_fingers = [];
|
||||
try {
|
||||
$stmtPins = $pdo->query("SELECT id_occupant, pin, no_hp, finger_id FROM occupant WHERE progress='used' AND status='approved' AND access='active'");
|
||||
while ($row = $stmtPins->fetch(PDO::FETCH_ASSOC)) {
|
||||
if (!empty($row['pin'])) {
|
||||
// Ambil 3 digit angka terakhir dari nomor HP untuk keperluan verifikasi khusus Telegram
|
||||
$hp_trim = preg_replace('/[^0-9]/', '', $row['no_hp']);
|
||||
$hp3 = substr($hp_trim, -3);
|
||||
if (empty($hp3))
|
||||
$hp3 = "000"; // Nilai default jika gagal ambil
|
||||
$valid_pins[] = [
|
||||
"id" => (int) $row['id_occupant'],
|
||||
"pin" => $row['pin'],
|
||||
"hp3" => $hp3
|
||||
];
|
||||
}
|
||||
if (!empty($row['finger_id'])) {
|
||||
// Kumpulkan ID sidik jari untuk mencocokan pada alat ESP32
|
||||
$active_fingers[] = $row['finger_id'];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
$response["valid_pins"] = $valid_pins;
|
||||
$response["active_fingers"] = $active_fingers;
|
||||
// Mengecek apakah ada perintah hapus sidik jari dari web yang perlu diteruskan ke alat
|
||||
$deleteQueueFile = 'esp32_finger_delete_queue.json';
|
||||
if (file_exists($deleteQueueFile)) {
|
||||
$queueData = json_decode(file_get_contents($deleteQueueFile), true);
|
||||
if (is_array($queueData) && count($queueData) > 0) {
|
||||
$response["delete_virtual_fingers"] = $queueData;
|
||||
}
|
||||
}
|
||||
// Hapus instruksi lama agar tidak berulang kali dijalankan oleh ESP32
|
||||
if ($state['command'] == 'CHANGE' || $state['command'] == 'SCAN') {
|
||||
$state['command'] = 'IDLE';
|
||||
} else if ($state['command'] == 'BT_ON' && $bt_on) {
|
||||
$state['command'] = 'IDLE'; // Bluetooth sudah dinyalakan alat, perintah direset
|
||||
} else if ($state['command'] == 'BT_OFF' && !$bt_on) {
|
||||
$state['command'] = 'IDLE'; // Bluetooth sudah dimatikan alat, perintah direset
|
||||
}
|
||||
// Reset instruksi paksa layar TFT setelah berhasil dikirim ke ESP32
|
||||
if (isset($state['tft_force_screen']) && $state['tft_force_screen'] !== 'none') {
|
||||
$state['tft_force_screen'] = 'none';
|
||||
unset($state['form_code']);
|
||||
}
|
||||
// Simpan semua pembaruan state
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
// Endpoint yang digunakan ESP32 untuk mengirimkan hasil pemindaian jaringan WiFi
|
||||
if ($action == 'esp_post_scan') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
// Simpan hasil jaringan yang ditemukan ESP32 ke dalam file state
|
||||
if (isset($data['networks'])) {
|
||||
$state['scan_results'] = $data['networks'];
|
||||
$state['command'] = 'SCAN_COMPLETE'; // Tandakan scan sudah tuntas
|
||||
$state['last_seen'] = time();
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode(["success" => true]);
|
||||
} else {
|
||||
echo json_encode(["success" => false, "message" => "Invalid data"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Endpoint yang digunakan ESP32 untuk melapor keberhasilan atau kegagalan menghubungkan WiFi baru
|
||||
if ($action == 'report_change') {
|
||||
$status = isset($_GET['status']) ? $_GET['status'] : 'idle';
|
||||
$msg = isset($_GET['msg']) ? $_GET['msg'] : '';
|
||||
$reason = isset($_GET['reason']) ? $_GET['reason'] : '';
|
||||
// JANGAN TERIMA LAPORAN KADALUWARSA
|
||||
// Jika sistem masih meminta pergantian ('CHANGE'), laporan yang masuk berarti dari proses masa lalu yang lambat.
|
||||
if ($state['command'] == 'CHANGE') {
|
||||
echo json_encode(["success" => true, "ignored_stale" => true, "message" => "Stale report ignored due to pending new command"]);
|
||||
exit;
|
||||
}
|
||||
// Perbarui status sesuai laporan terbaru ESP32
|
||||
$state['change_status'] = $status;
|
||||
$state['change_message'] = $msg;
|
||||
$state['change_reason'] = $reason;
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode(["success" => true]);
|
||||
exit;
|
||||
}
|
||||
// Endpoint untuk mendaftarkan penghuni baru yang diisi lewat layar sentuh TFT alat
|
||||
if ($action == 'register_occupant') {
|
||||
include 'koneksi.php';
|
||||
include 'notification_helper.php';
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$data) {
|
||||
echo json_encode(["status" => "error", "message" => "Invalid JSON data"]);
|
||||
exit;
|
||||
}
|
||||
// Pastikan field penting tidak kosong
|
||||
$required = ['name', 'no_hp', 'pin'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field]) || trim($data[$field]) === '') {
|
||||
echo json_encode(["status" => "error", "message" => "Field $field wajib diisi"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$name = trim($data['name']);
|
||||
$nik_nip = isset($data['nik_nip']) ? trim($data['nik_nip']) : '';
|
||||
$no_hp = trim($data['no_hp']);
|
||||
$pin = trim($data['pin']);
|
||||
// PIN harus terdiri dari angka dan tepat 6 digit
|
||||
if (!preg_match('/^\d{6}$/', $pin)) {
|
||||
echo json_encode(["status" => "error", "message" => "PIN harus 6 digit angka"]);
|
||||
exit;
|
||||
}
|
||||
// Cegah nomor HP ganda di sistem
|
||||
$cek = $pdo->prepare("SELECT COUNT(*) FROM occupant WHERE no_hp = ?");
|
||||
$cek->execute([$no_hp]);
|
||||
if ($cek->fetchColumn() > 0) {
|
||||
echo json_encode(["status" => "error", "message" => "Nomor Handphone sudah digunakan"]);
|
||||
exit;
|
||||
}
|
||||
// Fungsi membuat kode register 6 digit acak dan memastikan belum dipakai di database
|
||||
function generateCodeRegisterESP($pdo)
|
||||
{
|
||||
do {
|
||||
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM occupant WHERE code_register = ?");
|
||||
$check->execute([$code]);
|
||||
} while ($check->fetchColumn() > 0);
|
||||
return $code;
|
||||
}
|
||||
$code_register = generateCodeRegisterESP($pdo);
|
||||
$foto = ''; // Foto awal dikosongkan karena belum ambil gambar
|
||||
try {
|
||||
// Simpan data calon penghuni baru dengan status tertunda (suspend/rejected) sampai disetujui admin
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO occupant (name, nik_nip, no_hp, pin, foto, code_register, progress, status, access)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'unused', 'rejected', 'suspend')
|
||||
");
|
||||
$stmt->execute([$name, $nik_nip, $no_hp, $pin, $foto, $code_register]);
|
||||
$newId = $pdo->lastInsertId();
|
||||
// Kirim notifikasi sistem
|
||||
send_notification($pdo, 'mdi-account-multiple-plus', 'New Occupant Registration', "A new occupant registered via the TFT touchscreen: {$name}.", "Phone: {$no_hp}. Pending verification.", false);
|
||||
require_once 'PushHelper.php';
|
||||
PushHelper::broadcastToAdmins($pdo, 'New Occupant Registration', "{$name} registered and awaits verification.", '/manage_occupant.php');
|
||||
// Buat token unik sementara untuk pendaftaran foto dari web/ponsel
|
||||
$token = hash('sha256', random_bytes(32) . $newId . microtime(true));
|
||||
$stmtToken = $pdo->prepare("INSERT INTO foto_tokens (id_occupant, token, status) VALUES (?, ?, 'active')");
|
||||
$stmtToken->execute([$newId, $token]);
|
||||
// Buat link (URL) upload foto
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$basePath = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/\\');
|
||||
$url = $protocol . '://' . $host . $basePath . '/registrasi_foto.php?token=' . $token;
|
||||
// Balas ESP32 dengan sukses dan URL registrasi fotonya
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"code_register" => $code_register,
|
||||
"id_occupant" => $newId,
|
||||
"foto_url" => $url
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode([
|
||||
"status" => "error",
|
||||
"message" => "Gagal menyimpan: " . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Mengecek validitas kode pendaftaran yang diinput di ESP32
|
||||
if ($action == 'check_code_register') {
|
||||
include 'koneksi.php';
|
||||
$code = isset($_GET['code']) ? trim($_GET['code']) : (isset($_POST['code']) ? trim($_POST['code']) : '');
|
||||
if (empty($code)) {
|
||||
echo json_encode(["status" => "error", "message" => "Kode pendaftaran kosong"]);
|
||||
exit;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT id_occupant, foto, progress FROM occupant WHERE code_register = ?");
|
||||
$stmt->execute([$code]);
|
||||
$occupant = $stmt->fetch();
|
||||
if (!$occupant) {
|
||||
echo json_encode(["status" => "error", "message" => "Kode tidak ditemukan"]);
|
||||
exit;
|
||||
}
|
||||
// Jika kodenya sudah pernah digunakan, tolak
|
||||
if ($occupant['progress'] === 'used') {
|
||||
echo json_encode(["status" => "error", "message" => "Kode sudah digunakan!"]);
|
||||
exit;
|
||||
}
|
||||
// Cek apakah pengguna sudah melengkapi upload foto
|
||||
if (!empty($occupant['foto'])) {
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"code_register" => $code,
|
||||
"id_occupant" => $occupant['id_occupant'],
|
||||
"has_foto" => true
|
||||
]);
|
||||
} else {
|
||||
// Jika belum ada foto, buatkan token link lagi
|
||||
$token = hash('sha256', random_bytes(32) . $occupant['id_occupant'] . microtime(true));
|
||||
$stmtToken = $pdo->prepare("INSERT INTO foto_tokens (id_occupant, token, status) VALUES (?, ?, 'active')");
|
||||
$stmtToken->execute([$occupant['id_occupant'], $token]);
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$basePath = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/\\');
|
||||
$url = $protocol . '://' . $host . $basePath . '/registrasi_foto.php?token=' . $token;
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"code_register" => $code,
|
||||
"id_occupant" => $occupant['id_occupant'],
|
||||
"has_foto" => false,
|
||||
"foto_url" => $url
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Mengecek apakah alat ESP32 sudah selesai mengambil sidik jari (via web dashboard biasanya)
|
||||
if ($action == 'check_finger_status') {
|
||||
include_once 'koneksi.php';
|
||||
$code = isset($_REQUEST['code']) ? trim($_REQUEST['code']) : '';
|
||||
if (empty($code)) {
|
||||
echo json_encode(["status" => "error", "message" => "Code is empty"]);
|
||||
exit;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT finger_id FROM occupant WHERE code_register = ?");
|
||||
$stmt->execute([$code]);
|
||||
$row = $stmt->fetch();
|
||||
// Jika sidik jari tidak kosong dan valid, potong ID dengan hash agar aman dikirim ulang
|
||||
if ($row && !empty($row['finger_id']) && $row['finger_id'] !== '-' && $row['finger_id'] !== '0' && strtolower(trim($row['finger_id'])) !== 'null') {
|
||||
$hashedFingerId = substr(hash('sha256', $row['finger_id']), 0, 9);
|
||||
echo json_encode(["status" => "success", "finger_id" => $hashedFingerId]);
|
||||
} else {
|
||||
echo json_encode(["status" => "waiting"]); // Tunggu alat kirim ID jari
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Menyimpan data ID sidik jari (finger_id) yang baru saja berhasil di-scan oleh alat ESP32
|
||||
if ($action == 'register_finger') {
|
||||
include_once 'koneksi.php';
|
||||
// Ambil kode register dan ID jari dari URL atau body JSON
|
||||
$code = isset($_REQUEST['code']) ? trim($_REQUEST['code']) : '';
|
||||
$finger_id = isset($_REQUEST['finger_id']) ? trim($_REQUEST['finger_id']) : '';
|
||||
if (empty($code) || empty($finger_id)) {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if ($data) {
|
||||
$code = isset($data['code']) ? trim($data['code']) : $code;
|
||||
$finger_id = isset($data['finger_id']) ? trim($data['finger_id']) : $finger_id;
|
||||
}
|
||||
}
|
||||
if (!empty($code) && !empty($finger_id)) {
|
||||
try {
|
||||
// Update tabel penghuni, tandakan kodenya sudah terpakai
|
||||
$stmt = $pdo->prepare("UPDATE occupant SET finger_id = ?, progress = 'used' WHERE code_register = ?");
|
||||
$stmt->execute([$finger_id, $code]);
|
||||
echo json_encode(["status" => "success", "message" => "Finger ID $finger_id berhasil tersimpan"]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(["status" => "error", "message" => "Database error: " . $e->getMessage()]);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(["status" => "error", "message" => "Data req kosong"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Memproses akses buka pintu dengan sidik jari
|
||||
if ($action == 'fingerprint_access') {
|
||||
include 'koneksi.php';
|
||||
include 'notification_helper.php';
|
||||
require_once 'PushHelper.php';
|
||||
$finger_id_req = isset($_GET['finger_id']) ? trim($_GET['finger_id']) : (isset($_POST['finger_id']) ? trim($_POST['finger_id']) : '');
|
||||
// Buka kunci pintu dengan menuliskan status "unlocked" ke file
|
||||
$statusFile = 'door_status.txt';
|
||||
file_put_contents($statusFile, 'unlocked');
|
||||
$id_occupant = 1; // Nilai bawaan jika ID tidak ditemukan
|
||||
if (!empty($finger_id_req)) {
|
||||
$stmt = $pdo->prepare("SELECT id_occupant, name FROM occupant WHERE finger_id = ?");
|
||||
$stmt->execute([$finger_id_req]);
|
||||
$occ = $stmt->fetch();
|
||||
if ($occ) {
|
||||
$id_occupant = $occ['id_occupant'];
|
||||
// Kirim notifikasi jika pengguna dikenali
|
||||
send_notification($pdo, 'mdi-fingerprint', 'Door Unlocked (Biometric)', "The FTM room door was unlocked via fingerprint by {$occ['name']}.", 'System recorded a door unlock event.', false);
|
||||
PushHelper::broadcastToAdmins($pdo, 'Door Unlocked', "The FTM room door was unlocked via fingerprint by {$occ['name']}.", '/access.php');
|
||||
} else {
|
||||
// Kirim peringatan jika sidik jari belum dikenal/misterius
|
||||
send_notification($pdo, 'mdi-fingerprint', 'Door Unlocked', "The door was unlocked using an unrecognized Finger ID ({$finger_id_req}).", 'Please consult logs.', false);
|
||||
}
|
||||
} else {
|
||||
send_notification($pdo, 'mdi-fingerprint', 'Door Unlocked', 'The door was unlocked via fingerprint (No ID parsed).', 'Please consult logs.', false);
|
||||
}
|
||||
// Membuat token validasi laporan jika masuk via kode QR
|
||||
$report_token = isset($_GET['report_token']) ? trim($_GET['report_token']) : '';
|
||||
if (!empty($report_token)) {
|
||||
$tokenFile = __DIR__ . '/valid_report_tokens.json';
|
||||
$validTokens = [];
|
||||
if (file_exists($tokenFile)) {
|
||||
$validTokens = json_decode(file_get_contents($tokenFile), true) ?: [];
|
||||
}
|
||||
$validTokens[$report_token] = [
|
||||
'occ_id' => $id_occupant,
|
||||
'created' => time(),
|
||||
'type' => 'fingerprint'
|
||||
];
|
||||
file_put_contents($tokenFile, json_encode($validTokens, JSON_PRETTY_PRINT));
|
||||
}
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"id_occupant" => $id_occupant,
|
||||
"message" => "Door unlocked"
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// Memproses akses buka pintu dengan kode PIN darurat
|
||||
if ($action == 'emergency_access') {
|
||||
include 'koneksi.php';
|
||||
include 'notification_helper.php';
|
||||
require_once 'PushHelper.php';
|
||||
$pin = isset($_GET['pin']) ? trim($_GET['pin']) : (isset($_POST['pin']) ? trim($_POST['pin']) : '');
|
||||
// Buka kunci pintu dengan menuliskan status "unlocked" ke file
|
||||
$statusFile = 'door_status.txt';
|
||||
file_put_contents($statusFile, 'unlocked');
|
||||
$id_occupant = 1; // Nilai bawaan
|
||||
if (!empty($pin)) {
|
||||
$stmt = $pdo->prepare("SELECT id_occupant, name FROM occupant WHERE pin = ?");
|
||||
$stmt->execute([$pin]);
|
||||
$occ = $stmt->fetch();
|
||||
if ($occ) {
|
||||
$id_occupant = $occ['id_occupant'];
|
||||
send_notification($pdo, 'mdi-door-open', 'Door Unlocked', "The FTM room door was unlocked via emergency access by {$occ['name']}.", 'System recorded a door unlock event.', false);
|
||||
PushHelper::broadcastToAdmins($pdo, 'Door Unlocked', "The FTM room door was unlocked via emergency contact by {$occ['name']}.", '/access.php');
|
||||
} else {
|
||||
send_notification($pdo, 'mdi-door-open', 'Door Unlocked', "The door was unlocked using an unknown PIN.", 'Please consult the system logs.', false);
|
||||
}
|
||||
} else {
|
||||
// Fallback kalau kosong
|
||||
send_notification($pdo, 'mdi-door-open', 'Door Unlocked', 'The door was unlocked via emergency access (No PIN parsed).', 'Please consult the system logs.', false);
|
||||
}
|
||||
// Membuat token validasi laporan jika alat membutuhkannya
|
||||
$report_token = isset($_GET['report_token']) ? trim($_GET['report_token']) : '';
|
||||
if (!empty($report_token)) {
|
||||
$tokenFile = __DIR__ . '/valid_report_tokens.json';
|
||||
$validTokens = [];
|
||||
if (file_exists($tokenFile)) {
|
||||
$validTokens = json_decode(file_get_contents($tokenFile), true) ?: [];
|
||||
}
|
||||
$validTokens[$report_token] = [
|
||||
'occ_id' => $id_occupant,
|
||||
'created' => time(),
|
||||
'type' => 'emergency'
|
||||
];
|
||||
file_put_contents($tokenFile, json_encode($validTokens, JSON_PRETTY_PRINT));
|
||||
}
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"id_occupant" => $id_occupant,
|
||||
"message" => "Door unlocked"
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// Mengubah tampilan layar pada ESP32 secara paksa dari sistem
|
||||
if ($action == 'set_tft_screen') {
|
||||
$screen = isset($_POST['screen']) ? $_POST['screen'] : '';
|
||||
$form_code = isset($_POST['form_code']) ? $_POST['form_code'] : '';
|
||||
if (!empty($screen)) {
|
||||
$state['tft_force_screen'] = $screen;
|
||||
if (!empty($form_code)) {
|
||||
$state['form_code'] = $form_code; // Selipkan kode registrasi
|
||||
} else {
|
||||
unset($state['form_code']);
|
||||
}
|
||||
file_put_contents($stateFile, json_encode($state));
|
||||
echo json_encode(["success" => true, "message" => "Screen updated"]);
|
||||
} else {
|
||||
echo json_encode(["success" => false, "message" => "Screen cannot be empty"]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
// Aksi untuk mengosongkan antrean hapus sidik jari setelah berhasil diproses ESP32
|
||||
if ($action == 'clear_delete_queue') {
|
||||
$deleteQueueFile = 'esp32_finger_delete_queue.json';
|
||||
if (file_exists($deleteQueueFile)) {
|
||||
unlink($deleteQueueFile); // Hapus antrean
|
||||
}
|
||||
echo json_encode(["status" => "success", "message" => "Queue cleared"]);
|
||||
exit;
|
||||
}
|
||||
// Jika 'action' tidak terdaftar atau kosong, respon invalid action
|
||||
echo json_encode(["success" => false, "message" => "Invalid action"]);
|
||||
?>
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
header("Location: login.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'koneksi.php';
|
||||
|
||||
function getDeviceName($userAgent) {
|
||||
$userAgent = strtolower($userAgent);
|
||||
if (strpos($userAgent, 'windows') !== false) return 'Windows PC';
|
||||
if (strpos($userAgent, 'macintosh') !== false || strpos($userAgent, 'mac os') !== false) return 'Mac OS PC';
|
||||
if (strpos($userAgent, 'android') !== false) return 'Android Device';
|
||||
if (strpos($userAgent, 'iphone') !== false || strpos($userAgent, 'ipad') !== false) return 'iOS Device';
|
||||
if (strpos($userAgent, 'linux') !== false) return 'Linux PC';
|
||||
return 'Unknown Device';
|
||||
}
|
||||
|
||||
function getBrowserName($userAgent) {
|
||||
if (strpos($userAgent, 'Chrome') !== false) return 'Google Chrome';
|
||||
if (strpos($userAgent, 'Firefox') !== false) return 'Mozilla Firefox';
|
||||
if (strpos($userAgent, 'Safari') !== false && strpos($userAgent, 'Chrome') === false) return 'Apple Safari';
|
||||
if (strpos($userAgent, 'Edge') !== false) return 'Microsoft Edge';
|
||||
if (strpos($userAgent, 'Opera') !== false || strpos($userAgent, 'OPR') !== false) return 'Opera';
|
||||
return 'Unknown Browser';
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
try {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_history (
|
||||
id_log INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id_account INT NOT NULL,
|
||||
device_info VARCHAR(255),
|
||||
ip_address VARCHAR(50),
|
||||
login_time DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM login_history WHERE id_account = ? ORDER BY login_time DESC");
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
// Graceful fallback
|
||||
}
|
||||
?>
|
||||
<div class="main-panel">
|
||||
<div class="content-wrapper">
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin">
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4 mb-xl-0 d-flex align-items-center">
|
||||
<a href="dashboard.php" class="btn btn-sm btn-light mr-3 d-flex align-items-center" style="border-radius: 6px; padding: 6px 12px; color: #555; border: 1px solid #e2e8f0; text-decoration: none;">
|
||||
<i class="mdi mdi-arrow-left" style="font-size: 14px; margin-right: 4px;"></i> Back
|
||||
</a>
|
||||
<div>
|
||||
<h3 class="font-weight-bold mb-1">Login History</h3>
|
||||
<h6 class="font-weight-normal mb-0">System access login history.</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin stretch-card">
|
||||
<div class="card" style="border: 1px solid #e2e8f0; border-radius: 10px;">
|
||||
<div class="card-body">
|
||||
<p class="card-title mb-3">Complete Login History</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped" id="authLogTable">
|
||||
<thead style="background: #f8f9fa;">
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>Login Time</th>
|
||||
<th>Device</th>
|
||||
<th>Browser</th>
|
||||
<th>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if(count($logs) > 0): ?>
|
||||
<?php $no = 1; foreach($logs as $log):
|
||||
$time = strtotime($log['login_time']);
|
||||
$formattedDate = date('d F Y', $time);
|
||||
$formattedTime = date('H:i:s', $time);
|
||||
|
||||
$device = getDeviceName($log['device_info']);
|
||||
$browser = getBrowserName($log['device_info']);
|
||||
?>
|
||||
<tr>
|
||||
<td style="font-weight: 500;"><?= $no++ ?></td>
|
||||
<td>
|
||||
<div style="font-weight: 600; color: #212529; font-size: 14px;"><?= $formattedDate ?></div>
|
||||
<div style="font-size: 12px; color: #6c757d;"><i class="mdi mdi-clock-outline mr-1"></i><?= $formattedTime ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<?php if(strpos($device, 'Windows') !== false || strpos($device, 'Mac') !== false || strpos($device, 'Linux') !== false): ?>
|
||||
<div style="width: 32px; height: 32px; border-radius: 50%; background: #eff6ff; display: flex; align-items: center; justify-content: center; margin-right: 10px;">
|
||||
<i class="mdi mdi-laptop" style="font-size: 16px; color: #3b82f6;"></i>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="width: 32px; height: 32px; border-radius: 50%; background: #f0fdf4; display: flex; align-items: center; justify-content: center; margin-right: 10px;">
|
||||
<i class="mdi mdi-cellphone" style="font-size: 16px; color: #10b981;"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<span style="font-weight: 500; font-size: 14px; color: #333;"><?= $device ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="color: #555; font-size: 13px;"><?= $browser ?></td>
|
||||
<td style="font-family: monospace; color: #4B49AC; font-weight: 600;"><?= htmlspecialchars($log['ip_address']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-4 text-muted">No login history found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include 'footer.php'; ?>
|
||||
<?php include 'copy.php'; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
if ($.fn.DataTable.isDataTable('#authLogTable')) {
|
||||
$('#authLogTable').DataTable().destroy();
|
||||
}
|
||||
$('#authLogTable').DataTable({
|
||||
"order": [], // Let it follow original SQL order (desc) but allow sorting
|
||||
"pageLength": 15,
|
||||
"lengthMenu": [10, 15, 25, 50, 100],
|
||||
"language": {
|
||||
"search": "Search History:",
|
||||
"lengthMenu": "Show _MENU_ records per page",
|
||||
"info": "Showing _START_ to _END_ of _TOTAL_ total records",
|
||||
"paginate": {
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
/**
|
||||
* Auto-delete bot message after 5 minutes with LIVE per-second countdown.
|
||||
* Like a real timer: 5:00, 4:59, 4:58 ... 0:01, 0:00 -> DELETE
|
||||
* Preserves inline keyboard button during countdown.
|
||||
*
|
||||
* CLI: php auto_delete.php <chat_id> <msg_id> <token> <type> <orig_text>
|
||||
*/
|
||||
ignore_user_abort(true);
|
||||
set_time_limit(350);
|
||||
|
||||
// Accept args from CLI or GET
|
||||
if (php_sapi_name() === 'cli') {
|
||||
$chat_id = isset($argv[1]) ? $argv[1] : '';
|
||||
$msg_id = isset($argv[2]) ? $argv[2] : '';
|
||||
$token = isset($argv[3]) ? $argv[3] : '';
|
||||
$type = isset($argv[4]) ? $argv[4] : '';
|
||||
$orig_text = isset($argv[5]) ? $argv[5] : '';
|
||||
$user_msg_id = isset($argv[6]) ? $argv[6] : '';
|
||||
} else {
|
||||
$chat_id = isset($_GET['chat_id']) ? $_GET['chat_id'] : '';
|
||||
$msg_id = isset($_GET['msg_id']) ? $_GET['msg_id'] : '';
|
||||
$token = isset($_GET['token']) ? $_GET['token'] : '';
|
||||
$type = isset($_GET['type']) ? $_GET['type'] : '';
|
||||
$orig_text = isset($_GET['text']) ? $_GET['text'] : '';
|
||||
$user_msg_id = isset($_GET['user_msg_id']) ? $_GET['user_msg_id'] : '';
|
||||
|
||||
ob_start();
|
||||
echo "OK";
|
||||
$size = ob_get_length();
|
||||
header("Content-Length: $size");
|
||||
header('Connection: close');
|
||||
ob_end_flush();
|
||||
if (function_exists('ob_flush')) ob_flush();
|
||||
flush();
|
||||
if (session_id()) session_write_close();
|
||||
}
|
||||
|
||||
if (empty($chat_id) || empty($msg_id)) exit;
|
||||
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$api = "https://api.telegram.org/bot" . $botToken;
|
||||
|
||||
// Default original texts per type
|
||||
$defaultTexts = [
|
||||
'pin' => "Silakan klik tombol di bawah ini untuk memulai verifikasi wajah.",
|
||||
'forgot' => "Akses panel pemulihan telah disiapkan.",
|
||||
'edit' => "Sistem Pembaruan Data Identitas telah disediakan untuk Anda."
|
||||
];
|
||||
if (empty($orig_text)) {
|
||||
$orig_text = isset($defaultTexts[$type]) ? $defaultTexts[$type] : "Tautan telah disiapkan.";
|
||||
}
|
||||
|
||||
$manifestPath = __DIR__ . "/manifest_{$type}_tokens.json";
|
||||
|
||||
// Read reply_markup from manifest to preserve the inline keyboard button
|
||||
$replyMarkup = '';
|
||||
if (file_exists($manifestPath)) {
|
||||
$tokens = json_decode(file_get_contents($manifestPath), true) ?: [];
|
||||
if (isset($tokens[$token]['reply_markup'])) {
|
||||
$replyMarkup = $tokens[$token]['reply_markup'];
|
||||
}
|
||||
}
|
||||
|
||||
$expiresAt = time() + 300;
|
||||
if (file_exists($manifestPath)) {
|
||||
$tokens = json_decode(file_get_contents($manifestPath), true) ?: [];
|
||||
if (isset($tokens[$token]['expires_at'])) {
|
||||
$expiresAt = $tokens[$token]['expires_at'];
|
||||
}
|
||||
}
|
||||
|
||||
$lastText = '';
|
||||
$loopCount = 0;
|
||||
|
||||
while (true) {
|
||||
$now = time();
|
||||
$remaining = $expiresAt - $now;
|
||||
if ($remaining <= 0) break;
|
||||
|
||||
// Check if token still exists every 5 seconds
|
||||
if ($loopCount % 5 === 0) {
|
||||
if (!file_exists($manifestPath)) break;
|
||||
$tokens = json_decode(file_get_contents($manifestPath), true) ?: [];
|
||||
if (!isset($tokens[$token])) break;
|
||||
}
|
||||
|
||||
$mins = intval($remaining / 60);
|
||||
$secs = $remaining % 60;
|
||||
$timeStr = sprintf("%d:%02d", $mins, $secs);
|
||||
|
||||
$newText = $orig_text . "\n⏳ <i>Sisa waktu: " . $timeStr . "</i>";
|
||||
|
||||
if ($newText !== $lastText) {
|
||||
$editData = [
|
||||
'chat_id' => $chat_id,
|
||||
'message_id' => $msg_id,
|
||||
'text' => $newText,
|
||||
'parse_mode' => 'HTML'
|
||||
];
|
||||
// Include reply_markup to preserve the inline keyboard button
|
||||
if (!empty($replyMarkup)) {
|
||||
$editData['reply_markup'] = $replyMarkup;
|
||||
}
|
||||
@file_get_contents($api . "/editMessageText?" . http_build_query($editData));
|
||||
$lastText = $newText;
|
||||
}
|
||||
|
||||
$loopCount++;
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
// Final: DELETE the entire message (text + button + everything)
|
||||
if (file_exists($manifestPath)) {
|
||||
$tokens = json_decode(file_get_contents($manifestPath), true) ?: [];
|
||||
if (isset($tokens[$token])) {
|
||||
@file_get_contents($api . "/deleteMessage?chat_id=$chat_id&message_id=$msg_id");
|
||||
if (!empty($user_msg_id)) {
|
||||
@file_get_contents($api . "/deleteMessage?chat_id=$chat_id&message_id=$user_msg_id");
|
||||
}
|
||||
unset($tokens[$token]);
|
||||
file_put_contents($manifestPath, json_encode($tokens, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
ignore_user_abort(true);
|
||||
set_time_limit(350);
|
||||
|
||||
if (php_sapi_name() === 'cli') {
|
||||
$chat_id = isset($argv[1]) ? $argv[1] : '';
|
||||
} else {
|
||||
$chat_id = isset($_GET['chat_id']) ? $_GET['chat_id'] : '';
|
||||
ob_start();
|
||||
echo "OK";
|
||||
$size = ob_get_length();
|
||||
header("Content-Length: $size");
|
||||
header('Connection: close');
|
||||
ob_end_flush();
|
||||
if (function_exists('ob_flush')) ob_flush();
|
||||
flush();
|
||||
if (session_id()) session_write_close();
|
||||
}
|
||||
|
||||
if (empty($chat_id)) exit;
|
||||
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$api = "https://api.telegram.org/bot" . $botToken;
|
||||
|
||||
sleep(300); // Wait 5 minutes
|
||||
|
||||
$manPathMng = __DIR__ . '/manifest_manage.json';
|
||||
if (file_exists($manPathMng)) {
|
||||
$mng = json_decode(file_get_contents($manPathMng), true) ?: [];
|
||||
if (isset($mng[$chat_id])) {
|
||||
$msgData = $mng[$chat_id];
|
||||
if (is_array($msgData)) {
|
||||
$msgId = $msgData['msg_id'];
|
||||
$userMsgId = $msgData['user_msg_id'] ?? null;
|
||||
@file_get_contents($api . "/deleteMessage?chat_id=$chat_id&message_id=$msgId");
|
||||
if ($userMsgId) {
|
||||
@file_get_contents($api . "/deleteMessage?chat_id=$chat_id&message_id=$userMsgId");
|
||||
}
|
||||
} else {
|
||||
$msgId = $msgData;
|
||||
@file_get_contents($api . "/deleteMessage?chat_id=$chat_id&message_id=$msgId");
|
||||
}
|
||||
unset($mng[$chat_id]);
|
||||
file_put_contents($manPathMng, json_encode($mng));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
/**
|
||||
* Auto-delete success message after 5 seconds with LIVE per-second countdown.
|
||||
* 5 → 4 → 3 → 2 → 1 → DELETE
|
||||
*
|
||||
* CLI: php auto_delete_quick.php <chat_id> <msg_id> <text>
|
||||
*/
|
||||
ignore_user_abort(true);
|
||||
set_time_limit(15);
|
||||
|
||||
// Accept args from CLI or GET
|
||||
if (php_sapi_name() === 'cli') {
|
||||
$chat_id = isset($argv[1]) ? $argv[1] : '';
|
||||
$msg_id = isset($argv[2]) ? $argv[2] : '';
|
||||
$orig_text = isset($argv[3]) ? $argv[3] : '✅ Data Berhasil diperbarui';
|
||||
} else {
|
||||
$chat_id = isset($_GET['chat_id']) ? $_GET['chat_id'] : '';
|
||||
$msg_id = isset($_GET['msg_id']) ? $_GET['msg_id'] : '';
|
||||
$orig_text = isset($_GET['text']) ? $_GET['text'] : '✅ Data Berhasil diperbarui';
|
||||
|
||||
ob_start();
|
||||
echo "OK";
|
||||
$size = ob_get_length();
|
||||
header("Content-Length: $size");
|
||||
header('Connection: close');
|
||||
ob_end_flush();
|
||||
if (function_exists('ob_flush')) ob_flush();
|
||||
flush();
|
||||
if (session_id()) session_write_close();
|
||||
}
|
||||
|
||||
if (empty($chat_id) || empty($msg_id)) exit;
|
||||
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$api = "https://api.telegram.org/bot" . $botToken;
|
||||
|
||||
// Wait for 5 seconds total before deleting (quiet period)
|
||||
sleep(4);
|
||||
|
||||
sleep(1);
|
||||
|
||||
// DELETE the entire message
|
||||
@file_get_contents($api . "/deleteMessage?chat_id=$chat_id&message_id=$msg_id");
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
<?php
|
||||
// bot.php - Webhook handler untuk Telegram Bot
|
||||
|
||||
include 'koneksi.php';
|
||||
include 'run_background.php';
|
||||
|
||||
// === GANTI DENGAN TOKEN BOT TELEGRAM ANDA ===
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$website = "https://api.telegram.org/bot" . $botToken;
|
||||
|
||||
// Ambil request dari Telegram
|
||||
$content = file_get_contents("php://input");
|
||||
$update = json_decode($content, TRUE);
|
||||
|
||||
if (!$update) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($update["message"])) {
|
||||
$chatId = $update["message"]["chat"]["id"];
|
||||
|
||||
// Command /start
|
||||
if (isset($update["message"]["text"]) && $update["message"]["text"] === "/start") {
|
||||
$text = "Halo! Saya adalah <b>Identia</b>.\n\n"
|
||||
. "Penting untuk diketahui: <b>Ini bukanlah Bot otomatis yang bisa menjawab pesan secara otomatis</b>. Ini adalah <b>saluran interaksi langsung</b> untuk terhubung secara personal dengan Admin Identia.\n\n"
|
||||
. "Namun, untuk memulai obrolan langsung dengan Admin, kami perlu memverifikasi data Anda terlebih dahulu.\n\n"
|
||||
. "👇 <b>Silakan Bagikan Kontak Terlebih Dahulu untuk Berinteraksi Langsung dengan Admin</b>\n\n"
|
||||
. "<i>Catatan: Setelah berhasil, Anda dapat mengakses menu bantuan dengan mengetik /help. Menu tersebut menyediakan akses mandiri secara ringkas untuk:</i>\n"
|
||||
. "• <i>Pengelolaan Akun & Akses Mandiri</i>";
|
||||
|
||||
// Meminta kontak dari user menggunakan ReplyKeyboardMarkup
|
||||
$keyboard = [
|
||||
'keyboard' => [
|
||||
[
|
||||
['text' => '📱 Bagikan Kontak Saya', 'request_contact' => true]
|
||||
]
|
||||
],
|
||||
'resize_keyboard' => true,
|
||||
'is_persistent' => true
|
||||
];
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
'reply_markup' => json_encode($keyboard)
|
||||
];
|
||||
|
||||
file_get_contents($website . "/sendMessage?" . http_build_query($postData));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Konfirmasi / Cek Nomor HP
|
||||
if (isset($update["message"]["contact"])) {
|
||||
$phoneNumber = $update["message"]["contact"]["phone_number"];
|
||||
|
||||
// Normalisasi nomor HP (telegram biasa mengirim format internasional, misal 628... atau +628...
|
||||
// Kita ubah menjadi 08... menyesuaikan format lokal
|
||||
$normalizedPhone = preg_replace('/^\+?62/', '0', $phoneNumber);
|
||||
|
||||
// Cek database di tabel occupant
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE no_hp = ? OR no_hp = ? OR no_hp = ?");
|
||||
$stmt->execute([
|
||||
$phoneNumber,
|
||||
$normalizedPhone,
|
||||
"62" . ltrim($normalizedPhone, '0')
|
||||
]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($occupant) {
|
||||
// Nomor terdaftar, simpan chat_id ke database
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE occupant ADD COLUMN telegram_id VARCHAR(100) NULL AFTER no_hp");
|
||||
} catch (PDOException $e) {
|
||||
}
|
||||
|
||||
$updateStmt = $pdo->prepare("UPDATE occupant SET telegram_id = ? WHERE id_occupant = ?");
|
||||
$updateStmt->execute([$chatId, $occupant['id_occupant']]);
|
||||
|
||||
$text = "✅ <b>Verifikasi Berhasil!</b>\n\n"
|
||||
. "Nomor Anda telah terverifikasi sebagai Penghuni <b>Identia</b> yang sah.\n\n"
|
||||
. "Saat ini, Anda sudah <b>Terhubung Langsung</b> dengan saluran pribadi Admin <b>Identia</b>.\n\n"
|
||||
. "Silakan sampaikan pertanyaan, laporan kendala, atau pesan Anda di sini kapan pun Anda butuhkan. Admin akan membalas pesan Anda dari Control Panel sesegera mungkin.";
|
||||
|
||||
$removeKeyboard = ['remove_keyboard' => true];
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
'reply_markup' => json_encode($removeKeyboard)
|
||||
];
|
||||
file_get_contents($website . "/sendMessage?" . http_build_query($postData));
|
||||
} else {
|
||||
// Nomor tidak terdaftar
|
||||
$text = "Maaf, nomor handphone Anda ($phoneNumber) tidak ditemukan pada daftar Penghuni Identia.\n\nPastikan Anda mendaftar melalui Admin terlebih dahulu.";
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text
|
||||
];
|
||||
file_get_contents($website . "/sendMessage?" . http_build_query($postData));
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if the message contains media
|
||||
if (
|
||||
isset($update["message"]["photo"]) ||
|
||||
isset($update["message"]["document"]) ||
|
||||
isset($update["message"]["voice"]) ||
|
||||
isset($update["message"]["audio"]) ||
|
||||
isset($update["message"]["video"]) ||
|
||||
isset($update["message"]["sticker"]) ||
|
||||
isset($update["message"]["animation"])
|
||||
) {
|
||||
|
||||
$messageId = $update["message"]["message_id"];
|
||||
|
||||
// Hapus pesan media yang dikirimkan user tersebut langsung dari Telegram
|
||||
$deleteData = [
|
||||
'chat_id' => $chatId,
|
||||
'message_id' => $messageId
|
||||
];
|
||||
file_get_contents($website . "/deleteMessage?" . http_build_query($deleteData));
|
||||
|
||||
// Kirim pesan peringatan ke user
|
||||
$text = "⚠️ <b>Pesan Ditolak</b>\n\nMaaf, Anda hanya dapat mengirim pesan berupa <b>Teks Biasa</b> ke Admin.\n\nGambar, stiker, dokumen, atau pesan suara akan otomatis dihapus oleh sistem.";
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML'
|
||||
];
|
||||
file_get_contents($website . "/sendMessage?" . http_build_query($postData));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Handle Teks Biasa (Occupant membalas/chat ke Admin atau kirim command khusus)
|
||||
if (isset($update["message"]["text"])) {
|
||||
$textMessage = trim($update["message"]["text"]);
|
||||
|
||||
// Verifikasi bahwa user telegram ini ada telegram_id nya di sistem
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE occupant ADD COLUMN telegram_id VARCHAR(100) NULL AFTER no_hp");
|
||||
} catch (PDOException $e) {
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE telegram_id = ?");
|
||||
$stmt->execute([$chatId]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($occupant) {
|
||||
$id_occupant = $occupant['id_occupant'];
|
||||
|
||||
// Dapatkan URL dasar untuk Web App
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http";
|
||||
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
||||
$uri = rtrim(dirname($_SERVER['PHP_SELF']), '/\\');
|
||||
$baseUrl = $protocol . "://" . $host . $uri;
|
||||
|
||||
// --- Cek Command Khusus (Tidak masuk ke database chats admin) ---
|
||||
if ($textMessage === '/help') {
|
||||
$manPathMng = __DIR__ . '/manifest_manage.json';
|
||||
$mng = file_exists($manPathMng) ? (json_decode(file_get_contents($manPathMng), true) ?: []) : [];
|
||||
|
||||
// Hapus pesan lama jika ada (mencegah duplikasi)
|
||||
if (isset($mng[$chatId])) {
|
||||
$oldMsgData = $mng[$chatId];
|
||||
if (is_array($oldMsgData)) {
|
||||
$oldMsgId = $oldMsgData['msg_id'];
|
||||
$oldUserMsgId = $oldMsgData['user_msg_id'] ?? null;
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id=$oldMsgId");
|
||||
if ($oldUserMsgId) {
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id=$oldUserMsgId");
|
||||
}
|
||||
} else {
|
||||
$oldMsgId = $oldMsgData;
|
||||
@file_get_contents($website . "/deleteMessage?chat_id=$chatId&message_id=$oldMsgId");
|
||||
}
|
||||
unset($mng[$chatId]);
|
||||
}
|
||||
|
||||
$text = "📚 <b>Pusat Bantuan & Manajemen Akun</b>\n\n"
|
||||
. "Silakan klik tombol <b>Manage</b> di bawah ini untuk membuka menu pengelolaan akun Anda secara mandiri.";
|
||||
|
||||
$webAppUrl = $baseUrl . "/occ.php?chat_id=" . $chatId;
|
||||
$btn = ['inline_keyboard' => [[['text' => 'Manage', 'web_app' => ['url' => $webAppUrl]]]]];
|
||||
$postData = ['chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'HTML', 'reply_markup' => json_encode($btn)];
|
||||
|
||||
$res = file_get_contents($website . "/sendMessage?" . http_build_query($postData));
|
||||
$resData = json_decode($res, true);
|
||||
|
||||
if (isset($resData['result']['message_id'])) {
|
||||
$msgId = $resData['result']['message_id'];
|
||||
$userMsgId = $update["message"]["message_id"];
|
||||
|
||||
$mng[$chatId] = [
|
||||
'msg_id' => $msgId,
|
||||
'user_msg_id' => $userMsgId
|
||||
];
|
||||
file_put_contents($manPathMng, json_encode($mng));
|
||||
runBackground(__DIR__ . '/auto_delete_manage.php', [$chatId]);
|
||||
} else {
|
||||
// Update manifest just in case we unset it above
|
||||
file_put_contents($manPathMng, json_encode($mng));
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Simpan Pesan Normal ke Admin ---
|
||||
// Account ID default biasanya di set ke 1
|
||||
$id_account = 1;
|
||||
|
||||
// Cek apakah pesan ini adalah sebuah reply
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE chats ADD COLUMN reply_to_chat_id INT NULL AFTER telegram_msg_id");
|
||||
} catch (PDOException $e) {
|
||||
}
|
||||
|
||||
$replyToId = null;
|
||||
if (isset($update['message']['reply_to_message']) && isset($update['message']['reply_to_message']['message_id'])) {
|
||||
$tgReplyId = $update['message']['reply_to_message']['message_id'];
|
||||
// Temukan chat lokal berdasarkan telegram_msg_id
|
||||
$qRep = $pdo->prepare("SELECT id_chat FROM chats WHERE telegram_msg_id = ? LIMIT 1");
|
||||
$qRep->execute([$tgReplyId]);
|
||||
$foundRep = $qRep->fetch();
|
||||
if ($foundRep)
|
||||
$replyToId = $foundRep['id_chat'];
|
||||
}
|
||||
|
||||
$insertStmt = $pdo->prepare("INSERT INTO chats (id_account, id_occupant, sender, message, is_read, telegram_msg_id, reply_to_chat_id) VALUES (?, ?, 'occupant', ?, 0, ?, ?)");
|
||||
$insertStmt->execute([$id_account, $occupant['id_occupant'], $textMessage, $update['message']['message_id'], $replyToId]);
|
||||
|
||||
// WEB PUSH NOTIFICATION DISPATCH (Notify Admins)
|
||||
require_once 'PushHelper.php';
|
||||
$qOcc = $pdo->prepare("SELECT name FROM occupant WHERE id_occupant = ?");
|
||||
$qOcc->execute([$occupant['id_occupant']]);
|
||||
$occName = $qOcc->fetchColumn() ?: "Resident";
|
||||
PushHelper::broadcastToAdmins($pdo, "New Message from {$occName}", strip_tags($textMessage), '/chats.php');
|
||||
|
||||
// ALL PREVIOUS MESSAGES TO THIS USER ARE NOW READ
|
||||
try {
|
||||
$pdo->exec("UPDATE chats SET is_read = 1 WHERE id_occupant = {$occupant['id_occupant']} AND sender = 'account'");
|
||||
} catch (PDOException $e) {
|
||||
}
|
||||
|
||||
// Update last_active
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE occupant ADD COLUMN last_active DATETIME NULL");
|
||||
} catch (PDOException $e) {
|
||||
}
|
||||
$updateLastActive = $pdo->prepare("UPDATE occupant SET last_active = NOW() WHERE id_occupant = ?");
|
||||
$updateLastActive->execute([$occupant['id_occupant']]);
|
||||
|
||||
} else {
|
||||
// Belum terhubung & memaksa untuk kirim kontak
|
||||
if (in_array($textMessage, ['/help'])) {
|
||||
$text = "⚠️ <b>Akses Ditolak</b>\n\nMaaf, perintah <b>$textMessage</b> dinonaktifkan sementara.\n\nAnda <b>Wajib Membagikan Kontak</b> agar sistem dapat memverifikasi identitas Anda terlebih dahulu sebelum fitur ini dapat digunakan.";
|
||||
} else {
|
||||
$text = "Sesi chat Anda belum terhubung dengan Identia.\n\n👇 <b>Silakan Bagikan Kontak Terlebih Dahulu untuk Berinteraksi Langsung dengan Admin</b>";
|
||||
}
|
||||
$keyboard = ['keyboard' => [[['text' => '📱 Bagikan Kontak Saya', 'request_contact' => true]]], 'resize_keyboard' => true, 'is_persistent' => true];
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
'reply_markup' => json_encode($keyboard)
|
||||
];
|
||||
file_get_contents($website . "/sendMessage?" . http_build_query($postData));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle Pesan Diedit (Occupant mengedit pesan di Telegram)
|
||||
if (isset($update["edited_message"])) {
|
||||
$chatId = $update["edited_message"]["chat"]["id"];
|
||||
|
||||
if (isset($update["edited_message"]["text"])) {
|
||||
$editedText = $update["edited_message"]["text"];
|
||||
$messageId = $update["edited_message"]["message_id"];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE telegram_id = ?");
|
||||
$stmt->execute([$chatId]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($occupant) {
|
||||
// Kita tandai sebagai (Diedit) di akhir teks agar admin tahu
|
||||
$newText = htmlspecialchars($editedText) . " <i class='mdi mdi-pencil-outline edit-tag' style='font-size:11px; margin-left:3px;' title='Diedit'></i>";
|
||||
|
||||
$updateStmt = $pdo->prepare("UPDATE chats SET message = ? WHERE telegram_msg_id = ? AND id_occupant = ?");
|
||||
$updateStmt->execute([$newText, $messageId, $occupant['id_occupant']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
// bot_polling.php - Script untuk menjalankan Telegram Bot via Long Polling di Localhost
|
||||
// Jalankan via CLI: php bot_polling.php
|
||||
|
||||
include 'koneksi.php';
|
||||
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$website = "https://api.telegram.org/bot" . $botToken;
|
||||
|
||||
echo "Memulai Telegram Bot (Polling Mode)...\n";
|
||||
echo "Tekan Ctrl+C untuk berhenti.\n\n";
|
||||
|
||||
// Fungsi untuk request API
|
||||
function request($url, $postData = []) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
if (!empty($postData)) {
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
// Penting: gunakan JSON untuk reply_markup jika berbentuk array
|
||||
if(isset($postData['reply_markup']) && is_array($postData['reply_markup'])) {
|
||||
$postData['reply_markup'] = json_encode($postData['reply_markup']);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return json_decode($res, true);
|
||||
}
|
||||
|
||||
// Fungsi Download File dari Telegram
|
||||
function downloadTelegramFile($fileId, $botToken, $type, $originalName = "") {
|
||||
$urlGetFile = "https://api.telegram.org/bot{$botToken}/getFile?file_id={$fileId}";
|
||||
$res = json_decode(file_get_contents($urlGetFile), true);
|
||||
|
||||
if (isset($res['ok']) && $res['ok']) {
|
||||
$filePathApi = $res['result']['file_path'];
|
||||
$downloadUrl = "https://api.telegram.org/file/bot{$botToken}/{$filePathApi}";
|
||||
|
||||
$ext = pathinfo($filePathApi, PATHINFO_EXTENSION);
|
||||
$fileName = time() . '_' . rand(1000, 9999) . '.' . $ext;
|
||||
if (!empty($originalName)) {
|
||||
$fileName = time() . '_' . preg_replace('/[^A-Za-z0-9.\-_]/', '', $originalName);
|
||||
}
|
||||
|
||||
$savePath = __DIR__ . '/images/chat_uploads/' . $fileName;
|
||||
file_put_contents($savePath, file_get_contents($downloadUrl));
|
||||
|
||||
$urlRelative = "images/chat_uploads/" . $fileName;
|
||||
|
||||
if ($type === "Photo") {
|
||||
return "<a href='{$urlRelative}' target='_blank'><img src='{$urlRelative}' style='max-width:200px; border-radius:8px; display:block;'></a>";
|
||||
} else if ($type === "Video") {
|
||||
return "<video controls style='max-width:200px; border-radius:8px;'><source src='{$urlRelative}' type='video/mp4'></video>";
|
||||
} else if ($type === "Voice") {
|
||||
return "<audio controls style='max-width:220px;'><source src='{$urlRelative}' type='audio/ogg'></audio>";
|
||||
} else {
|
||||
return "<a href='{$urlRelative}' target='_blank' style='display:flex; align-items:center; gap:12px; padding:10px; background:rgba(0,0,0,0.05); border-radius:8px; text-decoration:none; color:inherit;'>
|
||||
<i class='mdi mdi-file-document-outline' style='font-size:32px; color:#4b49ac;'></i>
|
||||
<span style='font-weight:600; font-size:13px; word-break:break-all;'>{$fileName}</span>
|
||||
</a>";
|
||||
}
|
||||
}
|
||||
return "[Attachment Failed]";
|
||||
}
|
||||
|
||||
// Hapus Webhook jika sebelumnya pernah diset (Webhook dan Polling tidak bisa bersamaan)
|
||||
request($website . "/deleteWebhook");
|
||||
|
||||
$offset = 0;
|
||||
|
||||
while (true) {
|
||||
// Ambil data (Long Polling)
|
||||
$response = request($website . "/getUpdates", [
|
||||
'offset' => $offset,
|
||||
'timeout' => 20 // Tahan koneksi sampai 20 detik jika tak ada pesan baru
|
||||
]);
|
||||
|
||||
if (!empty($response['ok']) && !empty($response['result'])) {
|
||||
foreach ($response['result'] as $update) {
|
||||
$offset = $update['update_id'] + 1; // Update offset agar pesan ini tidak ditarik lagi
|
||||
|
||||
if (isset($update["message"])) {
|
||||
$chatId = $update["message"]["chat"]["id"];
|
||||
$username = isset($update["message"]["from"]["first_name"]) ? $update["message"]["from"]["first_name"] : "User";
|
||||
|
||||
echo "[".date('Y-m-d H:i:s')."] Pesan dari $username ($chatId): ";
|
||||
|
||||
// Command /start
|
||||
if (isset($update["message"]["text"]) && $update["message"]["text"] === "/start") {
|
||||
echo "/start\n";
|
||||
$text = "Halo! Saya adalah *Identia*.\n\n"
|
||||
. "Penting untuk diketahui: *Ini bukanlah Bot otomatis yang bisa menjawab secara otomatis*. Ini adalah *saluran interaksi langsung* untuk terhubung secara personal dengan Admin Identia.\n\n"
|
||||
. "Namun, untuk memulai obrolan langsung dengan Admin, kami perlu memverifikasi data Anda terlebih dahulu.\n\n"
|
||||
. "👇 *Silakan klik tombol di bawah ini untuk Membagikan Kontak Anda* agar kami dapat mencocokkan nomor handphone Anda dengan sistem kami.";
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'Markdown',
|
||||
'reply_markup' => [
|
||||
'keyboard' => [
|
||||
[ ['text' => '📱 Bagikan Kontak Saya', 'request_contact' => true] ]
|
||||
],
|
||||
'resize_keyboard' => true,
|
||||
'one_time_keyboard' => true
|
||||
]
|
||||
];
|
||||
request($website."/sendMessage", $postData);
|
||||
continue; // lanjut ke update berikutnya
|
||||
}
|
||||
|
||||
// Konfirmasi / Cek Nomor HP
|
||||
if (isset($update["message"]["contact"])) {
|
||||
$phoneNumber = $update["message"]["contact"]["phone_number"];
|
||||
echo "[Share Contact: $phoneNumber]\n";
|
||||
|
||||
$normalizedPhone = preg_replace('/^\+?62/', '0', $phoneNumber);
|
||||
|
||||
// Cek database di tabel occupant
|
||||
try { $pdo->exec("ALTER TABLE occupant ADD COLUMN telegram_id VARCHAR(100) NULL AFTER no_hp"); } catch(PDOException $e) {}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE no_hp = ? OR no_hp = ? OR no_hp = ?");
|
||||
$stmt->execute([
|
||||
$phoneNumber,
|
||||
$normalizedPhone,
|
||||
"62" . ltrim($normalizedPhone, '0')
|
||||
]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($occupant) {
|
||||
$updateStmt = $pdo->prepare("UPDATE occupant SET telegram_id = ? WHERE id_occupant = ?");
|
||||
$updateStmt->execute([$chatId, $occupant['id_occupant']]);
|
||||
|
||||
$text = "✅ *Verifikasi Berhasil!*\n\n"
|
||||
. "Nomor Anda telah terverifikasi sebagai Penghuni *Identia* yang sah.\n\n"
|
||||
. "Saat ini, Anda sudah *Terhubung Langsung* dengan saluran pribadi Admin *Identia*.\n\n"
|
||||
. "Silakan sampaikan pertanyaan, laporan kendala, atau pesan Anda di sini kapan pun Anda butuhkan. Admin akan membalas pesan Anda dari Control Panel sesegera mungkin.";
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'Markdown',
|
||||
'reply_markup' => ['remove_keyboard' => true]
|
||||
];
|
||||
request($website."/sendMessage", $postData);
|
||||
} else {
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => "❌ Maaf, nomor handphone Anda ($phoneNumber) tidak ditemukan pada daftar penghuni Identia.\n\nPastikan Anda mendaftar melalui Admin terlebih dahulu."
|
||||
];
|
||||
request($website."/sendMessage", $postData);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Teks Biasa (Occupant membalas/chat ke Admin)
|
||||
if (isset($update["message"])) {
|
||||
$msgObj = $update["message"];
|
||||
$textMessage = "";
|
||||
$captionStr = isset($msgObj["caption"]) ? htmlspecialchars($msgObj["caption"]) : "";
|
||||
|
||||
if (isset($msgObj["text"])) {
|
||||
$textMessage = htmlspecialchars($msgObj["text"]);
|
||||
echo "Teks: $textMessage\n";
|
||||
} else {
|
||||
// Kalau bukan text biasa, kita abaikan saja agar tidak tersimpan di database
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($textMessage)) {
|
||||
try { $pdo->exec("ALTER TABLE occupant ADD COLUMN telegram_id VARCHAR(100) NULL AFTER no_hp"); } catch(PDOException $e) {}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE telegram_id = ?");
|
||||
$stmt->execute([$chatId]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($occupant) {
|
||||
$id_account = 1;
|
||||
try { $pdo->exec("ALTER TABLE chats ADD COLUMN reply_to_chat_id INT NULL AFTER telegram_msg_id"); } catch(PDOException $e) {}
|
||||
|
||||
$replyToId = null;
|
||||
if (isset($msgObj['reply_to_message']) && isset($msgObj['reply_to_message']['message_id'])) {
|
||||
$tgReplyId = $msgObj['reply_to_message']['message_id'];
|
||||
// Temukan chat lokal berdasarkan telegram_msg_id
|
||||
$qRep = $pdo->prepare("SELECT id_chat FROM chats WHERE telegram_msg_id = ? LIMIT 1");
|
||||
$qRep->execute([$tgReplyId]);
|
||||
$foundRep = $qRep->fetch();
|
||||
if ($foundRep) $replyToId = $foundRep['id_chat'];
|
||||
}
|
||||
|
||||
$insertStmt = $pdo->prepare("INSERT INTO chats (id_account, id_occupant, sender, message, is_read, telegram_msg_id, reply_to_chat_id) VALUES (?, ?, 'occupant', ?, 0, ?, ?)");
|
||||
$insertStmt->execute([$id_account, $occupant['id_occupant'], $textMessage, $msgObj['message_id'], $replyToId]);
|
||||
|
||||
// ALL PREVIOUS MESSAGES TO THIS USER ARE NOW READ
|
||||
try {
|
||||
$pdo->exec("UPDATE chats SET is_read = 1 WHERE id_occupant = {$occupant['id_occupant']} AND sender = 'account'");
|
||||
} catch(PDOException $e) {}
|
||||
|
||||
try { $pdo->exec("ALTER TABLE occupant ADD COLUMN last_active DATETIME NULL"); } catch(PDOException $e) {}
|
||||
$updateLastActive = $pdo->prepare("UPDATE occupant SET last_active = NOW() WHERE id_occupant = ?");
|
||||
$updateLastActive->execute([$occupant['id_occupant']]);
|
||||
|
||||
} else {
|
||||
$postData = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => "Sesi chat Anda belum terhubung dengan Identia.\n\nSilakan kirim perintah /start lalu klik tombol 'Bagikan Kontak Saya' terlebih dahulu untuk memulai obrolan dengan Admin."
|
||||
];
|
||||
request($website."/sendMessage", $postData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Pesan Diedit (Occupant mengedit pesan di Telegram)
|
||||
if (isset($update["edited_message"])) {
|
||||
$chatId = $update["edited_message"]["chat"]["id"];
|
||||
|
||||
if (isset($update["edited_message"]["text"])) {
|
||||
$editedText = $update["edited_message"]["text"];
|
||||
$messageId = $update["edited_message"]["message_id"];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE telegram_id = ?");
|
||||
$stmt->execute([$chatId]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($occupant) {
|
||||
$newText = htmlspecialchars($editedText) . " <i class='mdi mdi-pencil-outline edit-tag' style='font-size:11px; margin-left:3px;' title='Diedit'></i>";
|
||||
|
||||
$updateStmt = $pdo->prepare("UPDATE chats SET message = ? WHERE telegram_msg_id = ? AND id_occupant = ?");
|
||||
$updateStmt->execute([$newText, $messageId, $occupant['id_occupant']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sleep sebentar jika tidak ada payload baru sebelum lanjut polling
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
session_start();
|
||||
|
||||
// Generate a random string
|
||||
$permitted_chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789';
|
||||
$captcha_text = substr(str_shuffle($permitted_chars), 0, 5); // 5 characters for simplicity
|
||||
$_SESSION['captcha'] = $captcha_text;
|
||||
|
||||
// Create image dimensions
|
||||
$img_width = 140;
|
||||
$img_height = 45;
|
||||
|
||||
// Create image
|
||||
$image = imagecreatetruecolor($img_width, $img_height);
|
||||
|
||||
// Define minimalist colors
|
||||
$bg_color = imagecolorallocate($image, 248, 250, 252); // Soft light background (#f8fafc)
|
||||
$text_color = imagecolorallocate($image, 71, 85, 105); // Modern dark slate text (#475569)
|
||||
$noise_color = imagecolorallocate($image, 203, 213, 225); // Subtle noise color (#cbd5e1)
|
||||
|
||||
// Fill background
|
||||
imagefilledrectangle($image, 0, 0, $img_width, $img_height, $bg_color);
|
||||
|
||||
// Add minimal noise dots to avoid being completely plain, but very subtle
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
imagesetpixel($image, rand(0, $img_width), rand(0, $img_height), $noise_color);
|
||||
}
|
||||
|
||||
// Add text to image with stable placement
|
||||
$x = 22;
|
||||
for ($i = 0; $i < strlen($captcha_text); $i++) {
|
||||
$font_size = 5; // Largest built-in GD font
|
||||
$y = rand(13, 16); // Very slight vertical variation for readability
|
||||
|
||||
imagechar($image, $font_size, $x, $y, $captcha_text[$i], $text_color);
|
||||
$x += 20; // Consistent horizontal spacing
|
||||
}
|
||||
|
||||
header('Content-type: image/png');
|
||||
imagepng($image);
|
||||
imagedestroy($image);
|
||||
?>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
include 'header.php';
|
||||
include 'koneksi.php';
|
||||
|
||||
// Try to create columns if they don't exist yet
|
||||
try { $pdo->exec("ALTER TABLE chats ADD COLUMN deleted_by_occupant TINYINT DEFAULT 0 AFTER is_read"); } catch(PDOException $e) {}
|
||||
try { $pdo->exec("ALTER TABLE occupant ADD COLUMN last_active DATETIME NULL"); } catch(PDOException $e) {}
|
||||
try { $pdo->exec("ALTER TABLE occupant ADD COLUMN telegram_id VARCHAR(100) NULL AFTER no_hp"); } catch(PDOException $e) {}
|
||||
|
||||
// For simplicity, account is 1 since Admin views this
|
||||
$id_account = isset($_SESSION['id_account']) ? $_SESSION['id_account'] : 1;
|
||||
?>
|
||||
<div class="main-panel">
|
||||
<div class="content-wrapper">
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin">
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4 mb-xl-0">
|
||||
<h3 class="font-weight-bold">Chat Management</h3>
|
||||
<h6 class="font-weight-normal mb-0">Hubungi occupant Anda melalui Integrasi Telegram Bot.</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Contact List -->
|
||||
<div class="col-md-4 grid-margin stretch-card">
|
||||
<div class="card" style="border: 1px solid #e2e8f0; border-radius: 10px;">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom bg-light" style="border-radius: 10px 10px 0 0;">
|
||||
<h6 class="mb-0 font-weight-bold">Daftar Chat</h6>
|
||||
</div>
|
||||
<div id="contactList" style="height: 200px; overflow-y: auto;">
|
||||
<div class="p-4 text-center text-muted"><i class="mdi mdi-loading mdi-spin"></i> Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="col-md-8 grid-margin stretch-card">
|
||||
<div class="card" style="border: 1px solid #e2e8f0; border-radius: 10px; display: flex; flex-direction: column;">
|
||||
|
||||
<!-- Chat Header -->
|
||||
<div class="border-bottom p-3 d-flex align-items-center bg-light" id="chatHeader" style="border-radius: 10px 10px 0 0; display: none !important;">
|
||||
<img src="images/faces/user.jpg" id="activeChatAvatar" style="width:40px;height:40px;border-radius:50%;object-fit:cover;border:2px solid #fff;box-shadow:0 2px 5px rgba(0,0,0,0.1);">
|
||||
<div class="ml-3">
|
||||
<h6 class="mb-0 font-weight-bold" id="activeChatName">Pilih Chat</h6>
|
||||
<small class="text-muted" id="activeChatStatus">Belum ada obrolan</small>
|
||||
</div>
|
||||
<input type="hidden" id="activeOccupantId" value="0">
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="chatEmptyState" class="d-flex flex-column align-items-center justify-content-center" style="height: 200px; background:#f4f5f7;">
|
||||
<div style="background:#e0e7ff; width:80px; height:80px; border-radius:50%; display:flex; align-items:center; justify-content:center; margin-bottom:15px;">
|
||||
<i class="mdi mdi-forum-outline text-primary" style="font-size:40px;"></i>
|
||||
</div>
|
||||
<h5 class="text-muted font-weight-bold">Pilih Occupant</h5>
|
||||
<p class="text-muted" style="font-size:13px;">Klik nama di sebelah kiri untuk melihat pesan otomatis dari Telegram</p>
|
||||
</div>
|
||||
|
||||
<!-- Chat History -->
|
||||
<div id="chatHistory" style="height: 200px; overflow-y: auto; padding: 20px; background-color: #f8fafc; background-image: url('images/walpaper.jpg'); background-size: cover; background-position: center; display: none; flex-direction: column; gap: 15px;">
|
||||
<!-- Messages will appear here -->
|
||||
</div>
|
||||
|
||||
<!-- Chat Footer -->
|
||||
<div id="chatFooter" class="border-top p-3" style="background:#fff; border-radius: 0 0 10px 10px; display: none;">
|
||||
<form id="formChat" class="d-flex align-items-center" style="gap: 10px;">
|
||||
<input type="text" id="chatInput" class="form-control" autocomplete="off" placeholder="Ketik pesan untuk dikirim ke Telegram..." style="border-radius: 20px; padding: 10px 20px; flex: 1; border: 1px solid #ced4da;">
|
||||
<button type="submit" class="btn btn-primary" style="width: 45px; height: 45px; border-radius: 50%; padding: 0; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i class="mdi mdi-send"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-item { padding: 15px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.2s; display: flex; align-items: center; }
|
||||
.contact-item:hover { background: #f8fafc; }
|
||||
.contact-item.active { background: #eff6ff; border-left: 4px solid #4b49ac; }
|
||||
.contact-msg-preview { font-size: 12px; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; display: block; }
|
||||
.chat-bubble { padding: 10px 15px; border-radius: 15px; max-width: 75%; font-size: 13.5px; line-height: 1.4; position: relative; }
|
||||
.chat-left { align-self: flex-start; background: #ffffff; border: 1px solid #e2e8f0; border-bottom-left-radius: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); }
|
||||
.chat-right { align-self: flex-end; background: #4b49ac; color: white; border-bottom-right-radius: 0; box-shadow: 0 2px 6px rgba(75, 73, 172, 0.2); }
|
||||
.chat-time { font-size: 10px; margin-top: 5px; opacity: 0.7; display: block; }
|
||||
.chat-right .chat-time { text-align: right; }
|
||||
.badge-unread { background: #ef4444; color: white; font-size: 10px; border-radius: 10px; padding: 2px 6px; font-weight: bold; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentOccupantId = 0;
|
||||
|
||||
function fetchContacts() {
|
||||
$.post('chat_api.php', { action: 'get_contacts' }, function(res) {
|
||||
if (res.status === 'success') {
|
||||
let html = '';
|
||||
res.data.forEach(c => {
|
||||
let isActive = c.id_occupant == currentOccupantId ? 'active' : '';
|
||||
let lastMsg = c.last_message ? c.last_message : 'Belum ada pesan';
|
||||
let unread = c.unread_count > 0 ? `<span class="badge-unread">${c.unread_count}</span>` : '';
|
||||
|
||||
let telegramIcon = c.telegram_id ? '<i class="mdi mdi-telegram text-info" title="Telegram Terhubung" style="font-size:16px;"></i>' : '<i class="mdi mdi-cellphone text-muted" title="Belum Terhubung" style="font-size:16px;"></i>';
|
||||
|
||||
html += `
|
||||
<div class="contact-item ${isActive}" onclick="openChat(${c.id_occupant}, '${c.name.replace(/'/g, "\\'")}', '${c.foto}', '${c.status}')">
|
||||
<div style="position:relative;">
|
||||
<img src="${c.foto}" style="width:45px;height:45px;border-radius:50%;object-fit:cover;border:1px solid #ddd;">
|
||||
<div style="position:absolute; bottom:-4px; right:-2px; background:#fff; border-radius:50%; width:18px; height:18px; display:flex; align-items:center; justify-content:center; box-shadow:0 1px 3px rgba(0,0,0,0.2);">${telegramIcon}</div>
|
||||
</div>
|
||||
<div class="ml-3" style="flex:1; min-width:0;">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="font-weight-bold text-dark" style="font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${c.name}</span>
|
||||
${unread}
|
||||
</div>
|
||||
<div class="contact-msg-preview mt-1">${lastMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
$('#contactList').html(html || '<div class="p-4 text-center text-muted">Tidak ada kontak.</div>');
|
||||
}
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
function openChat(id, name, foto, status) {
|
||||
currentOccupantId = id;
|
||||
$('#activeOccupantId').val(id);
|
||||
$('#activeChatName').text(name);
|
||||
$('#activeChatAvatar').attr('src', foto);
|
||||
$('#activeChatStatus').text(status === 'approved' ? 'Terverifikasi' : status);
|
||||
|
||||
// Highlight contact
|
||||
$('.contact-item').removeClass('active');
|
||||
event.currentTarget.classList.add('active');
|
||||
|
||||
// Toggle UI
|
||||
$('#chatEmptyState').hide();
|
||||
$('#chatHeader').css('display', 'flex').attr('style', 'border-radius: 10px 10px 0 0; display: flex !important;');
|
||||
$('#chatHistory').css('display', 'flex');
|
||||
$('#chatFooter').show();
|
||||
|
||||
fetchMessages();
|
||||
}
|
||||
|
||||
function fetchMessages() {
|
||||
if (currentOccupantId === 0) return;
|
||||
$.post('chat_api.php', { action: 'get_messages', id_occupant: currentOccupantId, viewer: 'account' }, function(res) {
|
||||
if (res.status === 'success') {
|
||||
let container = document.getElementById('chatHistory');
|
||||
let isScrolledBottom = container.scrollHeight - container.clientHeight <= container.scrollTop + 10;
|
||||
|
||||
let html = '';
|
||||
if (res.data.length === 0) {
|
||||
html = '<div class="text-center text-muted mt-5" style="font-size:13px;">Belum ada riwayat percakapan. Mulai kirim chat!</div>';
|
||||
} else {
|
||||
res.data.forEach(msg => {
|
||||
let isSelf = msg.sender === 'account';
|
||||
let cls = isSelf ? 'chat-right' : 'chat-left';
|
||||
let time = new Date(msg.created_at);
|
||||
let timeStr = ("0" + time.getHours()).slice(-2) + ":" + ("0" + time.getMinutes()).slice(-2);
|
||||
let check = '';
|
||||
if (isSelf) {
|
||||
let checkColor = msg.is_read == 1 ? '#4ade80' : '#cbd5e1';
|
||||
check = `<i class="mdi mdi-check-all" style="color:${checkColor}; font-size:14px; margin-right:4px;"></i>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="chat-bubble ${cls}">
|
||||
${msg.message}
|
||||
<span class="chat-time">${check}${timeStr}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
if (container.innerHTML !== html) {
|
||||
container.innerHTML = html;
|
||||
if (isScrolledBottom || container.scrollTop === 0) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'json');
|
||||
}
|
||||
|
||||
$('#formChat').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
let text = $('#chatInput').val().trim();
|
||||
if (!text || currentOccupantId === 0) return;
|
||||
|
||||
// Disabled input immediately
|
||||
$('#chatInput').prop('disabled', true);
|
||||
let submitBtn = $(this).find('button[type="submit"]');
|
||||
submitBtn.prop('disabled', true).html('<i class="mdi mdi-loading mdi-spin"></i>');
|
||||
|
||||
$.post('chat_api.php', { action: 'send_message', id_occupant: currentOccupantId, message: text, sender: 'account' }, function(res) {
|
||||
if (res.status === 'success') {
|
||||
$('#chatInput').val('');
|
||||
fetchMessages();
|
||||
fetchContacts();
|
||||
} else {
|
||||
showCustomAlert(res.message);
|
||||
}
|
||||
$('#chatInput').prop('disabled', false).focus();
|
||||
submitBtn.prop('disabled', false).html('<i class="mdi mdi-send"></i>');
|
||||
}, 'json').fail(function() {
|
||||
showCustomAlert("Gagal terhubung ke API");
|
||||
$('#chatInput').prop('disabled', false);
|
||||
submitBtn.prop('disabled', false).html('<i class="mdi mdi-send"></i>');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
fetchContacts();
|
||||
setInterval(fetchContacts, 7000);
|
||||
setInterval(fetchMessages, 3000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
<?php include 'copy.php'; ?>
|
||||
|
|
@ -0,0 +1,643 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
require_once 'PushHelper.php'; // Web Push Helper
|
||||
|
||||
// Default to 1 for development since session wasn't found globally yet.
|
||||
$id_account = isset($_SESSION['id_account']) ? $_SESSION['id_account'] : 1;
|
||||
|
||||
$action = isset($_POST['action']) ? $_POST['action'] : '';
|
||||
|
||||
// Create chats table if not exists
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS chats (
|
||||
id_chat INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id_account INT NOT NULL,
|
||||
id_occupant INT NOT NULL,
|
||||
sender ENUM('account', 'occupant') NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_read TINYINT DEFAULT 0,
|
||||
deleted_by_occupant TINYINT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
// Automatically add columns to existing tables if updating
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE chats ADD COLUMN deleted_by_occupant TINYINT DEFAULT 0 AFTER is_read");
|
||||
} catch(PDOException $e) { /* Column likely exists */ }
|
||||
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE occupant ADD COLUMN last_active DATETIME NULL");
|
||||
} catch(PDOException $e) { /* Column likely exists */ }
|
||||
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE occupant ADD COLUMN telegram_id VARCHAR(100) NULL AFTER no_hp");
|
||||
} catch(PDOException $e) { /* Column likely exists */ }
|
||||
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE chats ADD COLUMN telegram_msg_id INT NULL AFTER id_chat");
|
||||
} catch(PDOException $e) { /* Column likely exists */ }
|
||||
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE chats ADD COLUMN reply_to_chat_id INT NULL AFTER telegram_msg_id");
|
||||
} catch(PDOException $e) { /* Column likely exists */ }
|
||||
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE chats ADD COLUMN is_pinned TINYINT DEFAULT 0 AFTER reply_to_chat_id");
|
||||
} catch(PDOException $e) { /* Column likely exists */ }
|
||||
|
||||
// AUTO CLEANUP: Hapus chat yang usianya lebih dari 3 hari (72 Jam)
|
||||
try {
|
||||
$pdo->exec("DELETE FROM chats WHERE created_at < NOW() - INTERVAL 3 DAY AND is_pinned = 0");
|
||||
} catch(PDOException $e) {}
|
||||
|
||||
if ($action === 'get_contacts') {
|
||||
// Fetch all occupants, join with latest message and unread count
|
||||
$stmt = $pdo->query("SELECT o.*,
|
||||
(SELECT message FROM chats c WHERE c.id_occupant = o.id_occupant AND c.id_account = $id_account ORDER BY c.created_at DESC LIMIT 1) as last_message,
|
||||
(SELECT created_at FROM chats c WHERE c.id_occupant = o.id_occupant AND c.id_account = $id_account ORDER BY c.created_at DESC LIMIT 1) as last_time,
|
||||
(SELECT COUNT(*) FROM chats c WHERE c.id_occupant = o.id_occupant AND c.id_account = $id_account AND c.sender = 'occupant' AND c.is_read = 0) as unread_count
|
||||
FROM occupant o
|
||||
WHERE (SELECT id_chat FROM chats c WHERE c.id_occupant = o.id_occupant AND c.id_account = $id_account LIMIT 1) IS NOT NULL
|
||||
OR o.telegram_id IS NOT NULL
|
||||
ORDER BY last_time DESC, o.name ASC");
|
||||
$contacts = $stmt->fetchAll();
|
||||
|
||||
// Add default avatar handling
|
||||
foreach ($contacts as &$contact) {
|
||||
if (empty($contact['foto']) || !file_exists($contact['foto'])) {
|
||||
$contact['foto'] = 'images/faces/user.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $contacts]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_messages') {
|
||||
$id_occupant = isset($_POST['id_occupant']) ? (int)$_POST['id_occupant'] : 0;
|
||||
// Optional parameter to know who is fetching
|
||||
$viewer = isset($_POST['viewer']) ? $_POST['viewer'] : 'account';
|
||||
|
||||
if ($viewer === 'occupant') {
|
||||
// Update their online status since they are fetching messages
|
||||
$updateLastActive = $pdo->prepare("UPDATE occupant SET last_active = NOW() WHERE id_occupant = ?");
|
||||
$updateLastActive->execute([$id_occupant]);
|
||||
}
|
||||
|
||||
// Mark messages as read based on who is viewing
|
||||
if ($viewer === 'account') {
|
||||
$updateStmt = $pdo->prepare("UPDATE chats SET is_read = 1 WHERE id_account = ? AND id_occupant = ? AND sender = 'occupant' AND is_read = 0");
|
||||
} else {
|
||||
$updateStmt = $pdo->prepare("UPDATE chats SET is_read = 1 WHERE id_account = ? AND id_occupant = ? AND sender = 'account' AND is_read = 0");
|
||||
}
|
||||
$updateStmt->execute([$id_account, $id_occupant]);
|
||||
|
||||
// Fetch messages
|
||||
if ($viewer === 'occupant') {
|
||||
// If viewed by occupant, don't show deleted ones
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT c.*, r.message as reply_message, r.sender as reply_sender, r.id_chat as reply_id_chat
|
||||
FROM chats c
|
||||
LEFT JOIN chats r ON c.reply_to_chat_id = r.id_chat
|
||||
WHERE c.id_account = ? AND c.id_occupant = ? AND c.deleted_by_occupant = 0
|
||||
ORDER BY c.created_at ASC
|
||||
");
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT c.*, r.message as reply_message, r.sender as reply_sender, r.id_chat as reply_id_chat
|
||||
FROM chats c
|
||||
LEFT JOIN chats r ON c.reply_to_chat_id = r.id_chat
|
||||
WHERE c.id_account = ? AND c.id_occupant = ?
|
||||
ORDER BY c.created_at ASC
|
||||
");
|
||||
}
|
||||
$stmt->execute([$id_account, $id_occupant]);
|
||||
$messages = $stmt->fetchAll();
|
||||
|
||||
// Fetch pinned message
|
||||
$pinStmt = $pdo->prepare("SELECT * FROM chats WHERE id_account = ? AND id_occupant = ? AND is_pinned = 1 ORDER BY created_at DESC LIMIT 1");
|
||||
$pinStmt->execute([$id_account, $id_occupant]);
|
||||
$pinned = $pinStmt->fetch();
|
||||
|
||||
// Fetch occupant details for header
|
||||
$occStmt = $pdo->prepare("SELECT * FROM occupant WHERE id_occupant = ?");
|
||||
$occStmt->execute([$id_occupant]);
|
||||
$occupant = $occStmt->fetch();
|
||||
if (empty($occupant['foto']) || !file_exists($occupant['foto'])) {
|
||||
$occupant['foto'] = 'images/faces/user.jpg';
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $messages, 'occupant' => $occupant, 'pinned' => $pinned]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'send_message') {
|
||||
$id_occupant = isset($_POST['id_occupant']) ? (int)$_POST['id_occupant'] : 0;
|
||||
$message = isset($_POST['message']) ? trim($_POST['message']) : '';
|
||||
$sender = isset($_POST['sender']) ? $_POST['sender'] : 'account';
|
||||
$reply_to_chat_id = isset($_POST['reply_to_chat_id']) ? (int)$_POST['reply_to_chat_id'] : null;
|
||||
|
||||
// Process File Upload if present
|
||||
$hasAttachment = false;
|
||||
$attachmentPath = "";
|
||||
$attachmentName = "";
|
||||
$tgEndpoint = "sendMessage";
|
||||
$tgFileType = "";
|
||||
$localHtml = "";
|
||||
|
||||
if (isset($_FILES['attachment']) && $_FILES['attachment']['error'] === UPLOAD_ERR_OK) {
|
||||
$hasAttachment = true;
|
||||
$tmpName = $_FILES['attachment']['tmp_name'];
|
||||
$attachmentName = $_FILES['attachment']['name'];
|
||||
$ext = strtolower(pathinfo($attachmentName, PATHINFO_EXTENSION));
|
||||
$newFileName = time() . '_' . rand(1000, 9999) . '.' . $ext;
|
||||
|
||||
$uploadDir = __DIR__ . '/images/chat_uploads/';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
$attachmentPath = $uploadDir . $newFileName;
|
||||
move_uploaded_file($tmpName, $attachmentPath);
|
||||
|
||||
$urlRelative = "images/chat_uploads/" . $newFileName;
|
||||
|
||||
// Determine type for local HTML and Telegram API
|
||||
$photoExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
$audioExts = ['webm', 'ogg', 'mp3', 'wav', 'oga'];
|
||||
if (in_array($ext, $photoExts)) {
|
||||
$tgEndpoint = "sendPhoto";
|
||||
$tgFileType = "photo";
|
||||
// msg-type-img will be applied via JS if the content only contains this wrap
|
||||
$localHtml = "<div class='chat-img-wrap' onclick=\"openLightbox('{$urlRelative}')\"><img src='{$urlRelative}' class='chat-img'></div>";
|
||||
} else if (in_array($ext, $audioExts)) {
|
||||
$tgEndpoint = "sendVoice";
|
||||
$tgFileType = "voice";
|
||||
$localHtml = "<div class='voice-msg'><button class='voice-play-btn' onclick=\"toggleVoice(this,'{$urlRelative}')\"><i class='mdi mdi-play'></i></button><div class='voice-bar'><div class='voice-progress'></div></div><span class='voice-dur'>0:00</span></div>";
|
||||
} else {
|
||||
$tgEndpoint = "sendDocument";
|
||||
$tgFileType = "document";
|
||||
|
||||
$extLow = strtolower($ext);
|
||||
$iconClass = 'mdi-file-document-outline';
|
||||
$bgIconColor = '#94a3b8'; // default slate
|
||||
if ($extLow === 'pdf') { $iconClass = 'mdi-file-pdf-box'; $bgIconColor = '#ef4444'; } // red
|
||||
else if (in_array($extLow, ['doc','docx'])) { $iconClass = 'mdi-file-word-box'; $bgIconColor = '#3b82f6'; } // blue
|
||||
else if (in_array($extLow, ['xls','xlsx'])) { $iconClass = 'mdi-file-excel-box'; $bgIconColor = '#10b981'; } // green
|
||||
|
||||
$fileSize = round(filesize($attachmentPath) / 1024);
|
||||
$sizeStr = $fileSize > 1024 ? round($fileSize/1024, 1) . ' MB' : $fileSize . ' KB';
|
||||
$typeStr = strtoupper($extLow);
|
||||
|
||||
$localHtml = "
|
||||
<div class='wa-doc-bubble'>
|
||||
<div class='wa-doc-top'>
|
||||
<div class='wa-doc-icon' style='background:{$bgIconColor}'><i class='mdi {$iconClass}'></i></div>
|
||||
<div class='wa-doc-info'>
|
||||
<div class='wa-doc-name'>{$attachmentName}</div>
|
||||
<div class='wa-doc-meta'>{$typeStr} • {$sizeStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='wa-doc-bottom'>
|
||||
<a href='{$urlRelative}' target='_blank'>Open</a>
|
||||
<a href='{$urlRelative}' download>Save as...</a>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
// Append text message as caption to local HTML
|
||||
if (!empty($message)) {
|
||||
$localHtml .= "<div class='chat-caption' style='margin-top:8px;'>".htmlspecialchars($message)."</div>";
|
||||
}
|
||||
} else {
|
||||
$localHtml = htmlspecialchars($message); // no attachment, just text
|
||||
}
|
||||
|
||||
if ($sender === 'occupant') {
|
||||
// Keeps occupant online
|
||||
$updateLastActive = $pdo->prepare("UPDATE occupant SET last_active = NOW() WHERE id_occupant = ?");
|
||||
$updateLastActive->execute([$id_occupant]);
|
||||
}
|
||||
|
||||
// Only proceed if there is text or an attachment
|
||||
if ($id_occupant > 0 && (!empty($message) || $hasAttachment)) {
|
||||
$stmt = $pdo->prepare("INSERT INTO chats (id_account, id_occupant, sender, message, reply_to_chat_id) VALUES (?, ?, ?, ?, ?)");
|
||||
if ($stmt->execute([$id_account, $id_occupant, $sender, $localHtml, $reply_to_chat_id])) {
|
||||
$id_chat = $pdo->lastInsertId();
|
||||
|
||||
// Fetch Telegram Reply ID if replying
|
||||
$tgReplyId = null;
|
||||
if ($reply_to_chat_id) {
|
||||
$qRep = $pdo->prepare("SELECT telegram_msg_id FROM chats WHERE id_chat = ? LIMIT 1");
|
||||
$qRep->execute([$reply_to_chat_id]);
|
||||
$fRep = $qRep->fetch();
|
||||
if ($fRep && $fRep['telegram_msg_id']) {
|
||||
$tgReplyId = $fRep['telegram_msg_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// +++++ SEND TO TELEGRAM IF SENDER IS ACCOUNT +++++
|
||||
if ($sender === 'account') {
|
||||
$telStmt = $pdo->prepare("SELECT telegram_id FROM occupant WHERE id_occupant = ?");
|
||||
$telStmt->execute([$id_occupant]);
|
||||
$telegramUser = $telStmt->fetch();
|
||||
|
||||
if ($telegramUser && !empty($telegramUser['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$website = "https://api.telegram.org/bot".$botToken;
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $telegramUser['telegram_id']
|
||||
];
|
||||
|
||||
// IF Replying
|
||||
if ($tgReplyId) {
|
||||
$postData['reply_to_message_id'] = $tgReplyId;
|
||||
}
|
||||
|
||||
// IF Attachment
|
||||
if ($hasAttachment) {
|
||||
$postData[$tgFileType] = new CURLFile($attachmentPath);
|
||||
if (!empty($message)) {
|
||||
$postData['caption'] = $message;
|
||||
}
|
||||
} else {
|
||||
$postData['text'] = $message;
|
||||
}
|
||||
|
||||
$ch = curl_init($website."/".$tgEndpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($result) {
|
||||
$resJson = json_decode($result, true);
|
||||
if (isset($resJson['ok']) && $resJson['ok'] && isset($resJson['result']['message_id'])) {
|
||||
$tgMsgId = $resJson['result']['message_id'];
|
||||
$updateMsg = $pdo->prepare("UPDATE chats SET telegram_msg_id = ? WHERE id_chat = ?");
|
||||
$updateMsg->execute([$tgMsgId, $id_chat]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// +++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
// WEB PUSH NOTIFICATION DISPATCH
|
||||
if ($sender === 'occupant') {
|
||||
// Occupant sends message -> Notify Admins
|
||||
// Fetch occupant name to display in the push body
|
||||
$qOcc = $pdo->prepare("SELECT name FROM occupant WHERE id_occupant = ?");
|
||||
$qOcc->execute([$id_occupant]);
|
||||
$occName = $qOcc->fetchColumn() ?: "Resident";
|
||||
|
||||
$pushTitle = "New Message from {$occName}";
|
||||
$pushBody = strip_tags($localHtml);
|
||||
PushHelper::broadcastToAdmins($pdo, $pushTitle, $pushBody, '/chats.php');
|
||||
} else {
|
||||
// Admin sends message -> Notify the specific Occupant
|
||||
$pushTitle = "New Message from Admin";
|
||||
$pushBody = strip_tags($localHtml);
|
||||
PushHelper::sendPush($pdo, $id_occupant, $pushTitle, $pushBody, '/chats.php');
|
||||
}
|
||||
|
||||
$msgStmt = $pdo->prepare("SELECT * FROM chats WHERE id_chat = ?");
|
||||
$msgStmt->execute([$id_chat]);
|
||||
$newMsg = $msgStmt->fetch();
|
||||
echo json_encode(['status' => 'success', 'data' => $newMsg]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid data']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'login_occupant') {
|
||||
$no_hp = isset($_POST['no_hp']) ? trim($_POST['no_hp']) : '';
|
||||
$pin = isset($_POST['pin']) ? trim($_POST['pin']) : '';
|
||||
|
||||
if (empty($no_hp) || empty($pin)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Lengkapi form.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT id_occupant, name, status, access FROM occupant WHERE no_hp = ?");
|
||||
$stmt->execute([$no_hp]);
|
||||
$occ = $stmt->fetch();
|
||||
|
||||
if ($occ) {
|
||||
if ($occ['status'] !== 'approved' || $occ['access'] !== 'active') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akun belum disetujui atau sedang tidak aktif.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$verifyStmt = $pdo->prepare("SELECT id_occupant FROM occupant WHERE no_hp = ? AND pin = ?");
|
||||
$verifyStmt->execute([$no_hp, $pin]);
|
||||
if ($verifyStmt->fetch()) {
|
||||
$_SESSION['chat_occupant_id'] = $occ['id_occupant'];
|
||||
echo json_encode(['status' => 'success', 'data' => $occ]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'No HP atau PIN salah.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete_history') {
|
||||
$id_occupant = isset($_POST['id_occupant']) ? (int)$_POST['id_occupant'] : 0;
|
||||
if ($id_occupant > 0) {
|
||||
$stmt = $pdo->prepare("UPDATE chats SET deleted_by_occupant = 1 WHERE id_account = ? AND id_occupant = ?");
|
||||
$stmt->execute([$id_account, $id_occupant]);
|
||||
echo json_encode(['status' => 'success', 'message' => 'Chat history deleted from your view.']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid occupant ID.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete_message') {
|
||||
$id_chat = isset($_POST['id_chat']) ? (int)$_POST['id_chat'] : 0;
|
||||
$delete_type = isset($_POST['delete_type']) ? $_POST['delete_type'] : 'me'; // 'me' or 'everyone'
|
||||
|
||||
// Validate message belongs to account before deleting
|
||||
$stmt = $pdo->prepare("SELECT c.sender, c.telegram_msg_id, o.telegram_id
|
||||
FROM chats c
|
||||
LEFT JOIN occupant o ON c.id_occupant = o.id_occupant
|
||||
WHERE c.id_chat = ? AND c.id_account = ?");
|
||||
$stmt->execute([$id_chat, $id_account]);
|
||||
$msg = $stmt->fetch();
|
||||
|
||||
if ($msg) {
|
||||
if ($msg['sender'] === 'account') {
|
||||
// It's the admin's message
|
||||
// Delete from Database
|
||||
$delStmt = $pdo->prepare("DELETE FROM chats WHERE id_chat = ?");
|
||||
$delStmt->execute([$id_chat]);
|
||||
|
||||
// Delete from Telegram if exists AND delete_type is 'everyone'
|
||||
if ($delete_type === 'everyone' && !empty($msg['telegram_msg_id']) && !empty($msg['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$website = "https://api.telegram.org/bot".$botToken;
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $msg['telegram_id'],
|
||||
'message_id' => $msg['telegram_msg_id']
|
||||
];
|
||||
|
||||
$ch = curl_init($website."/deleteMessage");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
} else {
|
||||
// It's the occupant's message
|
||||
// Based on user request, admin can now delete occupant messages from their own view (Delete for Me)
|
||||
// Occupant messages cannot be deleted from Telegram via bot API anyway
|
||||
|
||||
$delStmt = $pdo->prepare("DELETE FROM chats WHERE id_chat = ?");
|
||||
$delStmt->execute([$id_chat]);
|
||||
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'error', 'message' => 'Message not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── FORWARD MESSAGE ──
|
||||
if ($action === 'forward_message') {
|
||||
$id_chat = isset($_POST['id_chat']) ? (int)$_POST['id_chat'] : 0;
|
||||
$target_occupant = isset($_POST['target_occupant']) ? (int)$_POST['target_occupant'] : 0;
|
||||
|
||||
if ($id_chat > 0 && $target_occupant > 0) {
|
||||
$srcStmt = $pdo->prepare("SELECT message FROM chats WHERE id_chat = ?");
|
||||
$srcStmt->execute([$id_chat]);
|
||||
$srcMsg = $srcStmt->fetch();
|
||||
|
||||
if ($srcMsg) {
|
||||
$fwdHtml = "<div class='fwd-label'><i class='mdi mdi-share'></i> Diteruskan</div>" . $srcMsg['message'];
|
||||
|
||||
$insStmt = $pdo->prepare("INSERT INTO chats (id_account, id_occupant, sender, message) VALUES (?, ?, 'account', ?)");
|
||||
$insStmt->execute([$id_account, $target_occupant, $fwdHtml]);
|
||||
$newId = $pdo->lastInsertId();
|
||||
|
||||
// Send to Telegram
|
||||
$telStmt = $pdo->prepare("SELECT telegram_id FROM occupant WHERE id_occupant = ?");
|
||||
$telStmt->execute([$target_occupant]);
|
||||
$tgUser = $telStmt->fetch();
|
||||
if ($tgUser && !empty($tgUser['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$plainText = strip_tags($srcMsg['message']);
|
||||
$postData = ['chat_id' => $tgUser['telegram_id'], 'text' => "[Diteruskan]\n" . $plainText];
|
||||
$ch = curl_init("https://api.telegram.org/bot{$botToken}/sendMessage");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
$res = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if ($res) {
|
||||
$rj = json_decode($res, true);
|
||||
if (isset($rj['ok']) && $rj['ok'] && isset($rj['result']['message_id'])) {
|
||||
$pdo->prepare("UPDATE chats SET telegram_msg_id = ? WHERE id_chat = ?")->execute([$rj['result']['message_id'], $newId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid forward data']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── EDIT MESSAGE ──
|
||||
if ($action === 'edit_message') {
|
||||
$id_chat = isset($_POST['id_chat']) ? (int)$_POST['id_chat'] : 0;
|
||||
$new_message = isset($_POST['new_message']) ? trim($_POST['new_message']) : '';
|
||||
|
||||
if ($id_chat > 0 && !empty($new_message)) {
|
||||
// Only allow editing account messages
|
||||
$stmt = $pdo->prepare("SELECT sender, telegram_msg_id, (SELECT telegram_id FROM occupant WHERE id_occupant = chats.id_occupant) as telegram_id FROM chats WHERE id_chat = ? AND id_account = ?");
|
||||
$stmt->execute([$id_chat, $id_account]);
|
||||
$msg = $stmt->fetch();
|
||||
|
||||
if ($msg && $msg['sender'] === 'account') {
|
||||
$formattedHtml = htmlspecialchars($new_message) . " <i class='mdi mdi-pencil-outline edit-tag' style='font-size:11px; margin-left:3px;' title='Diedit'></i>";
|
||||
|
||||
// Update local DB
|
||||
$updStmt = $pdo->prepare("UPDATE chats SET message = ? WHERE id_chat = ?");
|
||||
$updStmt->execute([$formattedHtml, $id_chat]);
|
||||
|
||||
// Update Telegram if telegram_msg_id exists
|
||||
if (!empty($msg['telegram_msg_id']) && !empty($msg['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$postData = [
|
||||
'chat_id' => $msg['telegram_id'],
|
||||
'message_id' => $msg['telegram_msg_id'],
|
||||
'text' => $new_message
|
||||
];
|
||||
$ch = curl_init("https://api.telegram.org/bot{$botToken}/editMessageText");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
echo json_encode(['status' => 'success', 'new_message' => $formattedHtml]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'Cannot edit this message']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── BULK DELETE MESSAGES ──
|
||||
if ($action === 'bulk_delete_messages') {
|
||||
$ids = isset($_POST['id_chats']) ? $_POST['id_chats'] : [];
|
||||
$deleteType = isset($_POST['delete_type']) ? $_POST['delete_type'] : 'me'; // 'everyone' or 'me'
|
||||
|
||||
if (is_array($ids) && count($ids) > 0) {
|
||||
$deletedCount = 0;
|
||||
foreach ($ids as $mid) {
|
||||
$mid = (int)$mid;
|
||||
if ($mid <= 0) continue;
|
||||
|
||||
// Validate message ownership and info
|
||||
$stmt = $pdo->prepare("SELECT sender, telegram_msg_id, telegram_id FROM chats c LEFT JOIN occupant o ON c.id_occupant = o.id_occupant WHERE id_chat = ? AND id_account = ?");
|
||||
$stmt->execute([$mid, $id_account]);
|
||||
$msg = $stmt->fetch();
|
||||
|
||||
if ($msg) {
|
||||
// Determine if we can delete from Telegram
|
||||
// Only 'account' messages can be deleted from Telegram by the bot.
|
||||
if ($deleteType === 'everyone' && $msg['sender'] === 'account' && !empty($msg['telegram_msg_id']) && !empty($msg['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$ch = curl_init("https://api.telegram.org/bot{$botToken}/deleteMessage");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, [
|
||||
'chat_id' => $msg['telegram_id'],
|
||||
'message_id' => $msg['telegram_msg_id']
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
// Delete from DB (applies for both 'me' and 'everyone' types)
|
||||
$delStmt = $pdo->prepare("DELETE FROM chats WHERE id_chat = ? AND id_account = ?");
|
||||
$delStmt->execute([$mid, $id_account]);
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success', 'message' => "Berhasil menghapus $deletedCount pesan."]);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tidak ada pesan yang dipilih']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── PIN / UNPIN MESSAGE ──
|
||||
if ($action === 'pin_message') {
|
||||
$id_chat = isset($_POST['id_chat']) ? (int)$_POST['id_chat'] : 0;
|
||||
$id_occupant = isset($_POST['id_occupant']) ? (int)$_POST['id_occupant'] : 0;
|
||||
if ($id_chat > 0 && $id_occupant > 0) {
|
||||
// Unpin all first
|
||||
$pdo->prepare("UPDATE chats SET is_pinned = 0 WHERE id_account = ? AND id_occupant = ?")->execute([$id_account, $id_occupant]);
|
||||
// Pin this one
|
||||
$pdo->prepare("UPDATE chats SET is_pinned = 1 WHERE id_chat = ?")->execute([$id_chat]);
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'error']);
|
||||
exit;
|
||||
}
|
||||
if ($action === 'unpin_message') {
|
||||
$id_chat = isset($_POST['id_chat']) ? (int)$_POST['id_chat'] : 0;
|
||||
if ($id_chat > 0) {
|
||||
$pdo->prepare("UPDATE chats SET is_pinned = 0 WHERE id_chat = ?")->execute([$id_chat]);
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── DELETE MULTIPLE MESSAGES ──
|
||||
if ($action === 'delete_multiple') {
|
||||
$ids = isset($_POST['ids']) ? json_decode($_POST['ids'], true) : [];
|
||||
if (is_array($ids) && count($ids) > 0) {
|
||||
foreach ($ids as $mid) {
|
||||
$mid = (int)$mid;
|
||||
$stmt = $pdo->prepare("SELECT sender, telegram_msg_id, (SELECT telegram_id FROM occupant WHERE id_occupant = chats.id_occupant) as telegram_id FROM chats WHERE id_chat = ? AND id_account = ?");
|
||||
$stmt->execute([$mid, $id_account]);
|
||||
$m = $stmt->fetch();
|
||||
if ($m && $m['sender'] === 'account') {
|
||||
$pdo->prepare("DELETE FROM chats WHERE id_chat = ?")->execute([$mid]);
|
||||
if (!empty($m['telegram_msg_id']) && !empty($m['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$ch = curl_init("https://api.telegram.org/bot{$botToken}/deleteMessage");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ['chat_id' => $m['telegram_id'], 'message_id' => $m['telegram_msg_id']]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'No IDs provided']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'request_call' && $id_occupant) {
|
||||
$telStmt = $pdo->prepare("SELECT telegram_id FROM occupant WHERE id_occupant = ?");
|
||||
$telStmt->execute([$id_occupant]);
|
||||
$telegramUser = $telStmt->fetch();
|
||||
|
||||
if ($telegramUser && !empty($telegramUser['telegram_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$website = "https://api.telegram.org/bot".$botToken;
|
||||
$msg = "📞 *PANGGILAN DARI ADMIN*\n\nAdmin SkyDash sedang mencoba menghubungi Anda via sistem. Silakan merespon melalui obrolan ini.";
|
||||
|
||||
$postData = [
|
||||
'chat_id' => $telegramUser['telegram_id'],
|
||||
'text' => $msg,
|
||||
'parse_mode' => 'Markdown'
|
||||
];
|
||||
|
||||
$ch = curl_init($website."/sendMessage");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($result) {
|
||||
$resJson = json_decode($result, true);
|
||||
if (isset($resJson['ok']) && $resJson['ok']) {
|
||||
echo json_encode(['status' => 'success']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menghubungi Telegram bot penghuni.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
$stmt = $pdo->query("SHOW COLUMNS FROM account");
|
||||
$cols = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo "Columns in ACCOUNT table:\n";
|
||||
foreach($cols as $col) {
|
||||
echo $col['Field'] . " - " . $col['Type'] . "\n";
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$captcha_input = $_POST['captcha'] ?? '';
|
||||
|
||||
if (isset($_SESSION['captcha'])) {
|
||||
if (strtoupper($captcha_input) === $_SESSION['captcha']) {
|
||||
echo json_encode(['status' => 'valid']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'invalid']);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['status' => 'invalid', 'error' => 'session_missing']);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid request method']);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
$stmt = $pdo->query("SHOW COLUMNS FROM occupant");
|
||||
$cols = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo "Columns in OCCUPANT table:\n";
|
||||
foreach($cols as $col) {
|
||||
echo $col['Field'] . "\n";
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
$files = glob("*.php");
|
||||
foreach ($files as $file) {
|
||||
if (in_array($file, ['bot.php', 'koneksi.php'])) continue;
|
||||
$content = file_get_contents($file);
|
||||
preg_match_all('/prepare\((["\'])(.*?)\1\).*?execute\(\[(.*?)\]\)/s', $content, $matches);
|
||||
foreach ($matches[2] as $idx => $query) {
|
||||
$qmCount = substr_count($query, '?');
|
||||
$execArgs = trim($matches[3][$idx]);
|
||||
$argCount = 0;
|
||||
if ($execArgs !== '') {
|
||||
$argCount = count(explode(',', $execArgs));
|
||||
}
|
||||
if ($qmCount != $argCount && !strpos($execArgs, '...')) {
|
||||
echo "Mismatch in $file: query has $qmCount ?, but execute has $argCount args.\n";
|
||||
echo "Query: $query\n";
|
||||
echo "Args: $execArgs\n\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "Check done.";
|
||||
?>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<?php include 'koneksi.php'; $stmt = $pdo->query('SHOW TABLES'); while ($row = $stmt->fetch(PDO::FETCH_NUM)) { echo $row[0]." "; } ?>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
require 'koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$response = [];
|
||||
|
||||
// 1. Check account table structure
|
||||
try {
|
||||
$stmt = $pdo->query("SHOW COLUMNS FROM account");
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
$response['account_columns'] = $columns;
|
||||
} catch (Exception $e) {
|
||||
$response['account_columns_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 2. Check push_subscriptions table
|
||||
try {
|
||||
$stmt = $pdo->query("SELECT * FROM push_subscriptions");
|
||||
$subs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$response['push_subscriptions_count'] = count($subs);
|
||||
$response['push_subscriptions'] = $subs;
|
||||
} catch (Exception $e) {
|
||||
$response['push_subscriptions_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 3. Check push_error.log
|
||||
if (file_exists('push_error.log')) {
|
||||
$response['push_error_log'] = file_get_contents('push_error.log');
|
||||
} else {
|
||||
$response['push_error_log'] = "File not found.";
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$username = $_POST['username'] ?? '';
|
||||
|
||||
if (empty($username)) {
|
||||
echo json_encode(['status' => 'empty']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT id_account FROM account WHERE username = ?");
|
||||
$stmt->execute([$username]);
|
||||
if ($stmt->fetch()) {
|
||||
echo json_encode(['status' => 'taken']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'available']);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "fiberid/push",
|
||||
"require": {
|
||||
"minishlink/web-push": "^9.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,65 @@
|
|||
<style>
|
||||
/* Custom Alert dari atas */
|
||||
#customCopyAlert {
|
||||
position: fixed;
|
||||
top: -100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #06c283ff; /* Warna hijau success */
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transition: top 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#customCopyAlert.show {
|
||||
top: 24px;
|
||||
}
|
||||
#customCopyAlert svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="customCopyAlert">
|
||||
<div id="customCopyAlertIcon" style="display:flex;">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span id="customCopyAlertText">Berhasil disalin!</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showCustomAlert(message, type = 'success') {
|
||||
var alertBox = document.getElementById("customCopyAlert");
|
||||
var alertText = document.getElementById("customCopyAlertText");
|
||||
var alertIcon = document.getElementById("customCopyAlertIcon");
|
||||
|
||||
if (message) {
|
||||
alertText.innerHTML = message;
|
||||
} else {
|
||||
alertText.innerText = "Berhasil disalin!";
|
||||
}
|
||||
|
||||
if (type === 'warning' || type === 'error' || (message && message.toLowerCase().includes('sudah digunakan'))) {
|
||||
alertBox.style.backgroundColor = '#f59e0b'; // Amber / Orange elegan
|
||||
alertIcon.innerHTML = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>';
|
||||
} else {
|
||||
alertBox.style.backgroundColor = '#06c283ff'; // Hijau success
|
||||
alertIcon.innerHTML = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
||||
}
|
||||
|
||||
alertBox.classList.add("show");
|
||||
|
||||
setTimeout(function() {
|
||||
alertBox.classList.remove("show");
|
||||
}, 2800);
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
<style>
|
||||
/* Global Custom Tooltip for Form Validation */
|
||||
#global-required-tooltip {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
z-index: 99999;
|
||||
border: 1px solid #ddd;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
#global-required-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 8px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent #ddd transparent;
|
||||
}
|
||||
#global-required-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 7px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent white transparent;
|
||||
}
|
||||
#global-required-tooltip .error-icon {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-family: serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="global-required-tooltip">
|
||||
<span class="error-icon">!</span>
|
||||
<span id="global-required-text">Please fill out this field.</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tooltip = document.getElementById('global-required-tooltip');
|
||||
const tooltipText = document.getElementById('global-required-text');
|
||||
let activeInput = null;
|
||||
|
||||
function hideTooltip() {
|
||||
tooltip.style.display = 'none';
|
||||
activeInput = null;
|
||||
}
|
||||
|
||||
// Capture the 'invalid' event on all inputs within the document
|
||||
document.addEventListener('invalid', function(e) {
|
||||
e.preventDefault(); // Prevent native browser tooltip from showing
|
||||
|
||||
const target = e.target;
|
||||
if (!target) return;
|
||||
|
||||
// Default to English text for various validation scenarios
|
||||
let message = "Please fill out this field.";
|
||||
if (target.validity.valueMissing) {
|
||||
if (target.type === 'checkbox') {
|
||||
message = "Please check this box if you want to proceed.";
|
||||
} else if (target.type === 'radio') {
|
||||
message = "Please select one of these options.";
|
||||
} else {
|
||||
message = "Please fill out this field.";
|
||||
}
|
||||
} else if (target.validity.typeMismatch) {
|
||||
if (target.type === 'email') {
|
||||
message = "Please enter an email address.";
|
||||
} else if (target.type === 'url') {
|
||||
message = "Please enter a URL.";
|
||||
} else {
|
||||
message = "Invalid value.";
|
||||
}
|
||||
} else if (target.validity.tooShort) {
|
||||
message = "Please lengthen this text to " + target.getAttribute('minlength') + " characters or more.";
|
||||
} else if (target.validity.tooLong) {
|
||||
message = "Please shorten this text to no more than " + target.getAttribute('maxlength') + " characters.";
|
||||
} else if (target.validity.patternMismatch) {
|
||||
message = target.title || "Please match the requested format.";
|
||||
}
|
||||
|
||||
// If there's a custom validity set by JS, use it instead
|
||||
if (target.validity.customError && target.validationMessage) {
|
||||
message = target.validationMessage;
|
||||
}
|
||||
|
||||
tooltipText.textContent = message;
|
||||
tooltip.style.display = 'flex';
|
||||
|
||||
// Calculate position based on the input element's bounds
|
||||
const rect = target.getBoundingClientRect();
|
||||
const topPos = rect.bottom + window.scrollY + 8;
|
||||
const leftPos = rect.left + window.scrollX + (rect.width / 2);
|
||||
|
||||
tooltip.style.top = topPos + 'px';
|
||||
tooltip.style.left = leftPos + 'px';
|
||||
|
||||
activeInput = target;
|
||||
|
||||
// Ensure input is focused and scrolled into view safely
|
||||
if (document.activeElement !== target) {
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Hide tooltip dynamically as soon as the user interacts and validates the field
|
||||
document.addEventListener('input', function(e) {
|
||||
if (activeInput && e.target === activeInput) {
|
||||
if (activeInput.validity.valid) {
|
||||
hideTooltip();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Hide tooltip when clicking anywhere outside the active input
|
||||
document.addEventListener('mousedown', function(e) {
|
||||
if (tooltip.style.display === 'flex' && e.target !== activeInput && !tooltip.contains(e.target)) {
|
||||
hideTooltip();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep tooltip positioned correctly on resize
|
||||
window.addEventListener('resize', function() {
|
||||
if (tooltip.style.display === 'flex' && activeInput) {
|
||||
const rect = activeInput.getBoundingClientRect();
|
||||
const topPos = rect.bottom + window.scrollY + 8;
|
||||
const leftPos = rect.left + window.scrollX + (rect.width / 2);
|
||||
tooltip.style.top = topPos + 'px';
|
||||
tooltip.style.left = leftPos + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide when scrolling for safety
|
||||
document.addEventListener('scroll', function() {
|
||||
if (tooltip.style.display === 'flex') {
|
||||
hideTooltip();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,350 @@
|
|||
<?php
|
||||
session_start();
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
header("Location: login.php");
|
||||
exit;
|
||||
}
|
||||
include 'koneksi.php';
|
||||
|
||||
$accountId = isset($_GET['id']) ? intval($_GET['id']) : 0;
|
||||
if (!$accountId) {
|
||||
header("Location: operations_account.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.*, p.id_profile, p.email AS p_email, p.moto, p.provinsi, p.kabupaten, p.kecamatan, p.desa, p.kode_pos,
|
||||
p.facebook, p.instagram, p.linkedin, p.whatsapp
|
||||
FROM account a
|
||||
LEFT JOIN profile p ON a.id_account = p.id_account
|
||||
WHERE a.id_account = ?
|
||||
");
|
||||
$stmt->execute([$accountId]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
header("Location: operations_account.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$fotoSrc = (!empty($user['foto']) && file_exists($user['foto'])) ? $user['foto'] : 'images/faces/user.jpg';
|
||||
$hasProfile = !empty($user['id_profile']);
|
||||
|
||||
include 'header.php';
|
||||
?>
|
||||
<div class="main-panel">
|
||||
<div class="content-wrapper">
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin">
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-8 mb-4 mb-xl-0">
|
||||
<h3 class="font-weight-bold">Dashboard Account</h3>
|
||||
<h6 class="font-weight-normal mb-0">Detailed view of collected data for <span class="text-primary"><?= htmlspecialchars($user['name']) ?></span></h6>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 grid-margin stretch-card">
|
||||
<div class="card" style="border:1px solid #e5e9f0;box-shadow:none;border-radius:12px;">
|
||||
<div class="card-body" style="padding: 32px;">
|
||||
|
||||
<!-- Header Row: Title & Fingerprint Registered -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="font-weight-bold mb-0" style="color:#1a1a1a;">Account Detail Overview</h4>
|
||||
<button class="pf-edit-btn" onclick="window.location.href='operations_account.php'"><i class="mdi mdi-arrow-left mr-1"></i>Back</button>
|
||||
</div>
|
||||
|
||||
<!-- Top Section: Photo and Account Details separated -->
|
||||
<div class="row mb-4">
|
||||
<!-- Sub-Card 1: Photo/Summary -->
|
||||
<div class="col-md-4 mb-4 mb-md-0 d-flex">
|
||||
<div style="border:1px solid #e5e9f0; border-radius:10px; padding:32px; background:#fff; width:100%;">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<img src="<?= htmlspecialchars($fotoSrc) ?>" alt="Profile image" class="img-fluid rounded-circle" style="width: 120px; height: 120px; object-fit: cover; border: 3px solid #e5e9f0; padding: 4px; cursor: pointer;"
|
||||
onclick="previewPhoto('<?= htmlspecialchars(addslashes($fotoSrc)) ?>')">
|
||||
</div>
|
||||
<h4 class="mb-1 font-weight-bold" style="color:#1a1a1a; display:inline-flex; align-items:center; justify-content:center;"><?= htmlspecialchars($user['name']) ?>
|
||||
<?php if ($user['status'] === 'approved'): ?>
|
||||
<i class="mdi mdi-check-decagram" style="color: #1da1f2; font-size: 20px; margin-left: 6px;" title="Verified Approved"></i>
|
||||
<?php endif; ?>
|
||||
</h4>
|
||||
<p class="text-muted mb-2">@<?= htmlspecialchars($user['username']) ?></p>
|
||||
|
||||
<?php if ($hasProfile && !empty($user['moto'])): ?>
|
||||
<p class="text-muted mt-2 mb-4" style="font-style: italic; font-size: 13px;">"<?= htmlspecialchars($user['moto']) ?>"</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-center pt-3" style="gap:10px;">
|
||||
<?php if (!empty($user['facebook'])): ?>
|
||||
<a href="https://facebook.com/<?= htmlspecialchars($user['facebook']) ?>" target="_blank" class="soc-btn"><i class="fab fa-facebook-f text-primary"></i></a>
|
||||
<?php else: ?>
|
||||
<span class="soc-btn disabled" title="Facebook not set"><i class="fab fa-facebook-f"></i></span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($user['instagram'])): ?>
|
||||
<a href="https://instagram.com/<?= htmlspecialchars($user['instagram']) ?>" target="_blank" class="soc-btn"><i class="fab fa-instagram" style="background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;"></i></a>
|
||||
<?php else: ?>
|
||||
<span class="soc-btn disabled" title="Instagram not set"><i class="fab fa-instagram"></i></span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($user['linkedin'])): ?>
|
||||
<a href="https://linkedin.com/in/<?= htmlspecialchars($user['linkedin']) ?>" target="_blank" class="soc-btn"><i class="fab fa-linkedin-in text-info"></i></a>
|
||||
<?php else: ?>
|
||||
<span class="soc-btn disabled" title="LinkedIn not set"><i class="fab fa-linkedin-in"></i></span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($user['whatsapp'])): ?>
|
||||
<a href="https://wa.me/<?= htmlspecialchars(preg_replace('/[^0-9]/','',$user['whatsapp'])) ?>" target="_blank" class="soc-btn"><i class="fab fa-whatsapp text-success"></i></a>
|
||||
<?php else: ?>
|
||||
<span class="soc-btn disabled" title="WhatsApp not set"><i class="fab fa-whatsapp"></i></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Security & Device Info below profile -->
|
||||
<div class="pt-2 mt-2" style="border-top: 1px solid #f0f0f0;">
|
||||
<div class="d-flex justify-content-between align-items-center" style="gap:12px;">
|
||||
<div style="flex:1; text-align:center;">
|
||||
<span class="d-block" style="font-size:10px; color:#888; font-weight:600; margin-bottom:2px;">Password</span>
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<span style="font-size:11px; font-weight:bold; letter-spacing:1px; margin-right:6px; color:#6c757d;">••••••</span>
|
||||
<button class="btn btn-xs" style="padding:1px 5px; font-size:11px; border-radius:3px; font-weight:bold; color:#fff; background:#4b49ac; border:1px solid #4b49ac;" onclick="openPinVerifikasiModal('<?= $user['id_account']; ?>')" title="Reveal Password"><i class="mdi mdi-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1; text-align:center;">
|
||||
<span class="d-block" style="font-size:10px; color:#888; font-weight:600; margin-bottom:2px;">Fingerprint Device</span>
|
||||
<span style="font-size:11px; color:#333; font-weight:500;"><?= !empty($user['finger_id']) && !empty($user['finger_device']) ? htmlspecialchars($user['finger_device']) : (!empty($user['finger_id']) ? 'Verified Device' : '<span style="color:#aaa; font-style:italic;">Unknown</span>') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-Card 1.5: Personal Information -->
|
||||
<div class="col-md-8 d-flex">
|
||||
<div style="border:1px solid #e5e9f0; border-radius:10px; padding:32px; background:#fff; width:100%;">
|
||||
<h5 class="font-weight-bold mb-4"><i class="mdi mdi-card-account-details-outline mr-2 text-primary"></i>Personal Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">Full Name</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= !empty($user['name']) ? htmlspecialchars($user['name']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">Username</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= !empty($user['username']) ? htmlspecialchars($user['username']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">Email Address</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['p_email']) ? htmlspecialchars($user['p_email']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">WhatsApp</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['whatsapp']) ? htmlspecialchars($user['whatsapp']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">Instagram</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['instagram']) ? htmlspecialchars($user['instagram']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">Facebook</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['facebook']) ? htmlspecialchars($user['facebook']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<span class="d-block" style="font-size:12px; color:#888; font-weight:600; margin-bottom:4px;">LinkedIn</span>
|
||||
<span style="font-size:14px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['linkedin']) ? htmlspecialchars($user['linkedin']) : '<span class="text-muted" style="font-size:12px; font-style:italic;">Not provided</span>' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Sub-Card 3: Address Details -->
|
||||
<div style="border:1px solid #e5e9f0; border-radius:10px; padding:32px; background:#fff;">
|
||||
<h5 class="font-weight-bold mb-4"><i class="mdi mdi-map-marker-outline mr-2 text-danger"></i>Address Details</h5>
|
||||
<div class="d-flex" style="gap:16px; flex-wrap:nowrap;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<span class="d-block" style="font-size:11px; color:#888; font-weight:600; margin-bottom:3px;">Province</span>
|
||||
<span style="font-size:12px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['provinsi']) ? htmlspecialchars($user['provinsi']) : '<span class="text-muted" style="font-style:italic;">-</span>' ?></span>
|
||||
</div>
|
||||
<div style="flex:1.5; min-width:0;">
|
||||
<span class="d-block" style="font-size:11px; color:#888; font-weight:600; margin-bottom:3px;">Regency / City</span>
|
||||
<span style="font-size:12px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['kabupaten']) ? htmlspecialchars($user['kabupaten']) : '<span class="text-muted" style="font-style:italic;">-</span>' ?></span>
|
||||
</div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<span class="d-block" style="font-size:11px; color:#888; font-weight:600; margin-bottom:3px;">District</span>
|
||||
<span style="font-size:12px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['kecamatan']) ? htmlspecialchars($user['kecamatan']) : '<span class="text-muted" style="font-style:italic;">-</span>' ?></span>
|
||||
</div>
|
||||
<div style="flex:1.5; min-width:0;">
|
||||
<span class="d-block" style="font-size:11px; color:#888; font-weight:600; margin-bottom:3px;">Village</span>
|
||||
<span style="font-size:12px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['desa']) ? htmlspecialchars($user['desa']) : '<span class="text-muted" style="font-style:italic;">-</span>' ?></span>
|
||||
</div>
|
||||
<div style="flex:0.5; min-width:0;">
|
||||
<span class="d-block" style="font-size:11px; color:#888; font-weight:600; margin-bottom:3px;">Postal Code</span>
|
||||
<span style="font-size:12px; color:#333; font-weight:500;"><?= $hasProfile && !empty($user['kode_pos']) ? htmlspecialchars($user['kode_pos']) : '<span class="text-muted" style="font-style:italic;">-</span>' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: VERIFY PIN ==================== -->
|
||||
<div class="modal fade" id="modalVerifyPin" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content" style="border: none; border-radius: 6px; box-shadow: 0 8px 30px rgba(0,0,0,0.1);">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title font-weight-bold"><i class="mdi mdi-lock mr-1 text-warning"></i> Verify PIN</h6>
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<i class="mdi mdi-shield-lock-outline" style="font-size:40px;color:#f59e0b;"></i>
|
||||
<p class="mt-2 mb-3" style="font-size:12px;color:#666;">Enter 6-digit PIN to view the original password.</p>
|
||||
<input type="hidden" id="verifyPinAccountId">
|
||||
<input type="password" id="verifyPinInput" class="form-control text-center" maxlength="6" placeholder="● ● ● ● ● ●" style="letter-spacing:8px;font-size:18px;font-weight:700;">
|
||||
<div id="verifyPinError" class="text-danger mt-2" style="display:none;font-size:12px;"><i class="mdi mdi-alert-circle mr-1"></i><span id="verifyPinErrorMsg"></span></div>
|
||||
<button type="button" class="btn btn-primary btn-sm btn-block mt-3" id="btnVerifyPin" onclick="submitPinVerify()"><i class="mdi mdi-check mr-1"></i>Verify</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: SHOW PASSWORD ==================== -->
|
||||
<div class="modal fade" id="modalShowPassword" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content" style="border: none; border-radius: 6px; box-shadow: 0 8px 30px rgba(0,0,0,0.1);">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title font-weight-bold"><i class="mdi mdi-key mr-1 text-success"></i> Original Password</h6>
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #92400e; margin-bottom: 8px;">
|
||||
<i class="mdi mdi-alert" style="color:#d97706;"></i>
|
||||
<div style="color:#92400e;font-size:11px;">Do not share this password with anyone!</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="text" id="revealedPassword" class="form-control" readonly style="font-size:15px;font-weight:600;letter-spacing:1px;">
|
||||
<div class="input-group-append"><span class="input-group-text" style="cursor:pointer;" onclick="copyRevealed()"><i class="fas fa-copy" id="btnCopyRevealed" style="color:#6c757d;"></i></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL: PHOTO PREVIEW ==================== -->
|
||||
<div class="modal fade" id="modalPhotoPreview" tabindex="-1" data-backdrop="static">
|
||||
<div class="modal-dialog" style="max-width: 400px;">
|
||||
<div class="modal-content" style="border-radius:12px; overflow:hidden;">
|
||||
<div class="modal-header py-2" style="border-bottom: none; position:relative; background:#f8fafc;">
|
||||
<h6 class="modal-title font-weight-bold" style="color:#1e293b;"><i class="mdi mdi-image-outline mr-1 text-primary"></i> Photo Preview</h6>
|
||||
<button type="button" class="close" data-dismiss="modal" style="position:absolute; right:15px; top:10px; color:#64748b;">×</button>
|
||||
</div>
|
||||
<div class="modal-body text-center" style="padding:0 20px 20px 20px; background:#f8fafc;">
|
||||
<img id="previewModalImage" src="" style="width:100%; max-height:350px; object-fit:cover; border-radius:8px; border:2px solid #e2e8f0; margin-bottom:15px; box-shadow:0 4px 12px rgba(0,0,0,0.05);">
|
||||
<a id="btnDownloadPreview" href="#" download="Account_Photo.jpg" class="btn btn-sm btn-primary w-100" style="border-radius:8px; font-weight:bold; letter-spacing:0.5px; padding:10px;">
|
||||
<i class="mdi mdi-download mr-1"></i>Download Photo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== CSS ==================== -->
|
||||
<style>
|
||||
.soc-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #f8f9fa; border: 1px solid #e2e8f0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.soc-btn:hover:not(.disabled) {
|
||||
background: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
text-decoration: none;
|
||||
}
|
||||
.soc-btn.disabled {
|
||||
background: #f5f5f5; border-color: #d4d4d8; color: #333;
|
||||
cursor: not-allowed; pointer-events: none; opacity: 0.8;
|
||||
}
|
||||
|
||||
.pf-edit-btn {
|
||||
font-size: 13px; font-weight: 500; color: #333; background: #fff;
|
||||
border: 1px solid #d4d4d8; border-radius: 6px; padding: 6px 16px;
|
||||
cursor: pointer; transition: all .15s; display: inline-flex; align-items: center;
|
||||
}
|
||||
.pf-edit-btn:hover {
|
||||
background: #f5f5f5; border-color: #bbb; text-decoration:none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
|
||||
<!-- ==================== JAVASCRIPT ==================== -->
|
||||
<script>
|
||||
function previewPhoto(url) {
|
||||
document.getElementById('previewModalImage').src = url;
|
||||
document.getElementById('btnDownloadPreview').href = url;
|
||||
$('#modalPhotoPreview').modal('show');
|
||||
}
|
||||
|
||||
function openPinVerifikasiModal(id) {
|
||||
document.getElementById("verifyPinAccountId").value = id;
|
||||
document.getElementById("verifyPinInput").value = '';
|
||||
document.getElementById("verifyPinError").style.display = 'none';
|
||||
$("#modalVerifyPin").modal("show");
|
||||
}
|
||||
|
||||
function submitPinVerify() {
|
||||
var id = document.getElementById("verifyPinAccountId").value;
|
||||
var pin = document.getElementById("verifyPinInput").value;
|
||||
var btn = document.getElementById("btnVerifyPin");
|
||||
|
||||
if (pin.length !== 6) {
|
||||
document.getElementById("verifyPinErrorMsg").innerText = "PIN must be 6 digits";
|
||||
document.getElementById("verifyPinError").style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
btn.innerHTML = '<i class="mdi mdi-loading mdi-spin mr-1"></i>Verifying...';
|
||||
btn.disabled = true;
|
||||
|
||||
$.post("verify_pin_account.php", { id_account: id, pin: pin }, function(d) {
|
||||
btn.innerHTML = '<i class="mdi mdi-check mr-1"></i>Verify';
|
||||
btn.disabled = false;
|
||||
|
||||
if (d.status === "success") {
|
||||
$("#modalVerifyPin").modal("hide");
|
||||
document.getElementById("revealedPassword").value = d.password;
|
||||
$("#modalShowPassword").modal("show");
|
||||
} else {
|
||||
document.getElementById("verifyPinErrorMsg").innerText = d.message || "Incorrect PIN!";
|
||||
document.getElementById("verifyPinError").style.display = "block";
|
||||
}
|
||||
}, "json").fail(function() {
|
||||
btn.innerHTML = '<i class="mdi mdi-check mr-1"></i>Verify';
|
||||
btn.disabled = false;
|
||||
document.getElementById("verifyPinErrorMsg").innerText = "Connection to server failed.";
|
||||
document.getElementById("verifyPinError").style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
function copyRevealed() {
|
||||
var v = document.getElementById("revealedPassword").value;
|
||||
var b = document.getElementById("btnCopyRevealed");
|
||||
navigator.clipboard.writeText(v).then(function() {
|
||||
b.classList.replace("fa-copy","fa-check");
|
||||
if (typeof showCustomAlert === 'function') showCustomAlert('Password copied successfully!');
|
||||
else alert('Password copied successfully!');
|
||||
setTimeout(function(){ b.classList.replace("fa-check","fa-copy"); }, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
// Script to setup the notifications table
|
||||
include 'koneksi.php';
|
||||
|
||||
try {
|
||||
$sql = "CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
icon VARCHAR(100) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_read TINYINT(1) DEFAULT 0,
|
||||
is_danger TINYINT(1) DEFAULT 0,
|
||||
is_deleted TINYINT(1) DEFAULT 0,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
|
||||
|
||||
$pdo->exec($sql);
|
||||
echo "Table 'notifications' created successfully.\n";
|
||||
} catch(PDOException $e) {
|
||||
echo "Error creating table: " . $e->getMessage() . "\n";
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
REGISTER PASSKEY - Account: 10 - Credential_id: sUO7Z43L2QsPxH2vwL2M3g (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: E9EiX7nPdtT4dGMz2moJZgd1vlmC_GamtHBUQStd0Rk (Length: 43)
|
||||
REGISTER PASSKEY - Account: 11 - Credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: E9EiX7nPdtT4dGMz2moJZgd1vlmC_GamtHBUQStd0Rk (Length: 43)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: QLX3dGnj4mZtqLl6GbPGdw (Length: 22)
|
||||
REGISTER PASSKEY - Account: 13 - Credential_id: pr7eOvZsXZqRry9L5k3d3g== (Length: 24)
|
||||
REGISTER PASSKEY - Account: 14 - Credential_id: rXZcL96fdmyDQ8mokJkN8Q== (Length: 24)
|
||||
REGISTER PASSKEY - Account: 15 - Credential_id: vxfzLHVmDq+gX0FeWgX5qw== (Length: 24)
|
||||
REGISTER PASSKEY - Account: 12 - Credential_id: dnpoSbxlNEfkAnf5kEpb1A (Length: 22)
|
||||
REGISTER PASSKEY - Account: 17 - Credential_id: 3fRfDtVVWasuLdeJdD11+Q== (Length: 24)
|
||||
REGISTER PASSKEY - Account: 16 - Credential_id: OnRBxK0kkgp3ckjadtT4Uw (Length: 22)
|
||||
REGISTER PASSKEY - Account: 19 - Credential_id: 3qpx43x6vJ4ECjxMeCvvfQ== (Length: 24)
|
||||
REGISTER PASSKEY - Account: 20 - Credential_id: A12QCr-Q8kB7u2UDxKdvHQ (Length: 22)
|
||||
REGISTER PASSKEY - Account: 21 - Credential_id: CKZZa9FfJjkoYcg3vxRZkQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: 3qpx43x6vJ4ECjxMeCvvfQ (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: CKZZa9FfJjkoYcg3vxRZkQ (Length: 22)
|
||||
VERIFY PASSKEY - Received credential_id: A12QCr+Q8kB7u2UDxKdvHQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: CKZZa9FfJjkoYcg3vxRZkQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: 3qpx43x6vJ4ECjxMeCvvfQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: 3qpx43x6vJ4ECjxMeCvvfQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: 3qpx43x6vJ4ECjxMeCvvfQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: CKZZa9FfJjkoYcg3vxRZkQ== (Length: 24)
|
||||
VERIFY PASSKEY - Received credential_id: 3qpx43x6vJ4ECjxMeCvvfQ== (Length: 24)
|
||||
REGISTER PASSKEY - Account: 1 - Credential_id: h5bcUhYN17UX9+kXRUUTmg== (Length: 24)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!isset($_POST['id_account'])) {
|
||||
echo json_encode(["status" => "error", "message" => "Data tidak valid."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_POST['id_account'];
|
||||
|
||||
try {
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM account WHERE id_account = ?");
|
||||
if ($stmt->execute([$id])) {
|
||||
echo json_encode(["status" => "success"]);
|
||||
} else {
|
||||
echo json_encode(["status" => "error", "message" => "Gagal menghapus data."]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(["status" => "error", "message" => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
include "koneksi.php";
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(["status" => "error", "message" => "Method tidak diizinkan"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_POST['id_occupant']) || empty($_POST['id_occupant'])) {
|
||||
echo json_encode(["status" => "error", "message" => "ID tidak ditemukan"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_POST['id_occupant'];
|
||||
|
||||
if (!is_numeric($id)) {
|
||||
echo json_encode(["status" => "error", "message" => "ID tidak valid"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pastikan kolom occupant_name ada di tabel history
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE history ADD COLUMN occupant_name VARCHAR(255) DEFAULT NULL");
|
||||
} catch (PDOException $e) {
|
||||
// Abaikan jika kolom sudah ada
|
||||
}
|
||||
|
||||
// Ambil data occupant (foto, finger_id, name)
|
||||
$getOcc = $pdo->prepare("SELECT name, foto, finger_id FROM occupant WHERE id_occupant = ?");
|
||||
$getOcc->execute([$id]);
|
||||
$row = $getOcc->fetch();
|
||||
|
||||
if ($row && !empty($row['finger_id'])) {
|
||||
$qFile = __DIR__ . '/esp32_finger_delete_queue.json';
|
||||
$queue = file_exists($qFile) ? json_decode(file_get_contents($qFile), true) : [];
|
||||
if (!is_array($queue)) $queue = [];
|
||||
$queue[] = $row['finger_id'];
|
||||
file_put_contents($qFile, json_encode($queue));
|
||||
}
|
||||
|
||||
// Simpan nama occupant ke history & pindahkan semua history ke Trash
|
||||
if ($row) {
|
||||
$updateHistory = $pdo->prepare("
|
||||
UPDATE history
|
||||
SET occupant_name = ?,
|
||||
is_deleted = 1,
|
||||
is_starred = 0,
|
||||
is_read = 1,
|
||||
deleted_at = IFNULL(deleted_at, NOW())
|
||||
WHERE occupant_id = ?
|
||||
");
|
||||
$updateHistory->execute([$row['name'], $id]);
|
||||
}
|
||||
|
||||
// Hapus chat yang terkait dengan occupant agar tidak ada orphaned messages
|
||||
$delChats = $pdo->prepare("DELETE FROM chats WHERE id_occupant = ?");
|
||||
$delChats->execute([$id]);
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM occupant WHERE id_occupant = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// Hapus file foto jika ada (kecuali foto default)
|
||||
if ($row && !empty($row['foto']) && file_exists(__DIR__ . '/' . $row['foto']) && strpos($row['foto'], 'images/faces/') === false) {
|
||||
@unlink(__DIR__ . '/' . $row['foto']);
|
||||
}
|
||||
echo json_encode(["status" => "success"]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
"status" => "error",
|
||||
"message" => "Data tidak ditemukan atau sudah dihapus"
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode([
|
||||
"status" => "error",
|
||||
"message" => "Gagal menghapus: " . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
ob_start();
|
||||
include 'koneksi.php';
|
||||
$output = ob_get_clean();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$id = $_POST['id_restriction'] ?? '';
|
||||
|
||||
if (empty($id)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID is required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("DELETE FROM room_restrictions WHERE id_restriction = ?");
|
||||
$result = $stmt->execute([$id]);
|
||||
if ($result) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Restriction deleted successfully.']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to delete: ' . implode(' ', $stmt->errorInfo())]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Exception: ' . $e->getMessage()]);
|
||||
}
|
||||
|
|
@ -0,0 +1,733 @@
|
|||
<?php include 'header.php'; ?>
|
||||
<div class="main-panel">
|
||||
<div class="content-wrapper" style="padding:8px 15px;">
|
||||
|
||||
<!-- TOOLBAR -->
|
||||
<div class="card mb-2" style="border-radius:10px;">
|
||||
<div class="card-body p-2 d-flex align-items-center flex-wrap" style="gap:8px;">
|
||||
<input type="text" id="projectName" class="form-control form-control-sm" style="width:150px;border-radius:6px;" value="Untitled">
|
||||
<select id="savedDesigns" class="form-control form-control-sm" style="width:140px;border-radius:6px;"><option value="0">-- New --</option></select>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-primary" onclick="TFT.save()" title="Save"><i class="ti-save"></i> Save</button>
|
||||
<button class="btn btn-outline-primary" onclick="TFT.loadList()" title="Load"><i class="ti-folder"></i></button>
|
||||
<button class="btn btn-outline-primary" onclick="TFT.exportCpp()" title="Export C++"><i class="ti-download"></i> C++</button>
|
||||
<button class="btn btn-success" onclick="TFT.sync()" title="Sync to ESP32"><i class="ti-pulse"></i> Sync</button>
|
||||
<button class="btn btn-info" onclick="TFT.pullESP()" title="Pull from ESP32"><i class="ti-import"></i> Pull</button>
|
||||
</div>
|
||||
<select id="orientationSelect" class="form-control form-control-sm" style="width:130px;border-radius:6px;" onchange="TFT.setOrientation(this.value)">
|
||||
<option value="landscape">Landscape 480×320</option>
|
||||
<option value="portrait">Portrait 320×480</option>
|
||||
</select>
|
||||
<div class="btn-group btn-group-sm ml-auto">
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="TFT.deleteSelected()" title="Delete"><i class="ti-trash"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="TFT.undo()" title="Undo"><i class="ti-back-left"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="TFT.redo()" title="Redo"><i class="ti-back-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN EDITOR -->
|
||||
<div class="d-flex" style="gap:8px; height:calc(100vh - 170px);">
|
||||
|
||||
<!-- LEFT: Component Library -->
|
||||
<div class="card" style="width:190px;min-width:190px;border-radius:10px;overflow-y:auto;">
|
||||
<div class="card-body p-2">
|
||||
<p class="clib-title">LAYOUTS</p>
|
||||
<div class="clib-item" draggable="true" data-comp="sidebar"><i class="ti-layout-sidebar-left"></i>Sidebar</div>
|
||||
<div class="clib-item" draggable="true" data-comp="header"><i class="ti-layout-tab-window"></i>Header / Navbar</div>
|
||||
<div class="clib-item" draggable="true" data-comp="card"><i class="ti-widget"></i>Card / Panel</div>
|
||||
<p class="clib-title mt-2">CONTROLS</p>
|
||||
<div class="clib-item" draggable="true" data-comp="button"><i class="ti-control-stop"></i>Button</div>
|
||||
<div class="clib-item" draggable="true" data-comp="toggle"><i class="ti-exchange-vertical"></i>Toggle Switch</div>
|
||||
<div class="clib-item" draggable="true" data-comp="progressbar"><i class="ti-stats-up"></i>Progress Bar</div>
|
||||
<p class="clib-title mt-2">DATA & TYPOGRAPHY</p>
|
||||
<div class="clib-item" draggable="true" data-comp="datavalue"><i class="ti-dashboard"></i>Data Value</div>
|
||||
<div class="clib-item" draggable="true" data-comp="label"><i class="ti-text"></i>Text / Label</div>
|
||||
<div class="clib-item" draggable="true" data-comp="icon"><i class="ti-star"></i>Icon</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Canvas + Hierarchy -->
|
||||
<div class="flex-grow-1 d-flex flex-column" style="gap:6px;min-width:0;">
|
||||
<!-- Hierarchy Tree -->
|
||||
<div class="card" style="border-radius:8px;max-height:90px;overflow-y:auto;">
|
||||
<div class="card-body p-1 px-2">
|
||||
<div id="hierarchyTree" style="font-size:11px;font-family:monospace;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Canvas -->
|
||||
<div class="card flex-grow-1" style="border-radius:10px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#2a2a2e;">
|
||||
<div id="canvasFrame" style="position:relative;width:480px;height:320px;background:#1e2223;box-shadow:0 4px 24px rgba(0,0,0,.4);overflow:hidden;border:1px solid #444;">
|
||||
<div id="tftCanvas" style="width:100%;height:100%;position:relative;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Screen Manager -->
|
||||
<div class="card" style="border-radius:8px;">
|
||||
<div class="card-body p-2 d-flex align-items-center" style="gap:6px;overflow-x:auto;">
|
||||
<span style="font-size:10px;color:#888;font-weight:700;letter-spacing:1px;white-space:nowrap;">SCREENS</span>
|
||||
<div id="screenTabs" class="d-flex" style="gap:4px;"></div>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="TFT.addScreen()" style="border-radius:6px;white-space:nowrap;"><i class="ti-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Property Inspector -->
|
||||
<div class="card" style="width:270px;min-width:270px;border-radius:10px;overflow-y:auto;" id="propsPanel">
|
||||
<div class="card-body p-2">
|
||||
<div id="propsContent">
|
||||
<p class="text-muted text-center mt-4" style="font-size:12px;">Select a component<br>to edit properties</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /d-flex main -->
|
||||
</div><!-- /content-wrapper -->
|
||||
<?php include 'footer.php'; ?>
|
||||
|
||||
<style>
|
||||
.clib-title{font-size:10px;font-weight:700;color:#999;letter-spacing:1px;margin-bottom:4px;}
|
||||
.clib-item{padding:7px 10px;border:1px solid #eee;border-radius:8px;margin-bottom:4px;cursor:grab;font-size:12px;display:flex;align-items:center;gap:8px;transition:all .15s;background:#fff;}
|
||||
.clib-item:hover{background:#f0eeff;border-color:#c5c3f0;transform:translateX(2px);}
|
||||
.clib-item i{font-size:15px;color:#4B49AC;width:18px;text-align:center;}
|
||||
.screen-tab{padding:5px 12px;border:1px solid #555;border-radius:6px;font-size:11px;cursor:pointer;background:#333;color:#ccc;white-space:nowrap;transition:.15s;}
|
||||
.screen-tab.active{background:#4B49AC;color:#fff;border-color:#4B49AC;}
|
||||
.screen-tab:hover{opacity:.85;}
|
||||
|
||||
/* Canvas Components */
|
||||
.tft-comp{position:absolute;box-sizing:border-box;cursor:move;user-select:none;transition:box-shadow .1s;}
|
||||
.tft-comp:hover{box-shadow:0 0 0 1px #7B7BFF;}
|
||||
.tft-comp.selected{box-shadow:0 0 0 2px #4B49AC !important;z-index:100;}
|
||||
.tft-comp .resize-handle{position:absolute;right:-3px;bottom:-3px;width:8px;height:8px;background:#4B49AC;border-radius:1px;cursor:nwse-resize;display:none;}
|
||||
.tft-comp.selected .resize-handle{display:block;}
|
||||
.tft-comp-label{position:absolute;top:-14px;left:0;font-size:8px;color:#888;white-space:nowrap;pointer-events:none;}
|
||||
|
||||
/* Property Inspector */
|
||||
.pi-section{margin-bottom:10px;border-bottom:1px solid #f0f0f0;padding-bottom:8px;}
|
||||
.pi-section:last-child{border-bottom:none;}
|
||||
.pi-title{font-size:9px;font-weight:700;color:#999;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px;}
|
||||
.pi-row{display:flex;gap:4px;margin-bottom:4px;align-items:center;}
|
||||
.pi-row label{font-size:10px;color:#666;min-width:18px;flex-shrink:0;}
|
||||
.pi-row input,.pi-row select{font-size:11px;padding:3px 5px;border:1px solid #e0e0e0;border-radius:4px;width:100%;background:#fff;}
|
||||
.pi-row input[type="color"]{width:32px;height:26px;padding:1px;cursor:pointer;flex-shrink:0;}
|
||||
.pi-row input[type="range"]{padding:0;}
|
||||
.pi-tabs{display:flex;gap:0;margin-bottom:8px;border-bottom:1px solid #eee;}
|
||||
.pi-tab{padding:5px 10px;font-size:10px;font-weight:600;cursor:pointer;border:none;background:none;color:#aaa;border-bottom:2px solid transparent;transition:.15s;}
|
||||
.pi-tab.active{color:#4B49AC;border-bottom-color:#4B49AC;}
|
||||
.pi-tab:hover{color:#4B49AC;}
|
||||
.rgb565-badge{font-size:9px;color:#888;margin-top:1px;}
|
||||
.pi-btn-group{display:flex;gap:2px;}
|
||||
.pi-btn-group button{flex:1;font-size:10px;padding:3px;border:1px solid #ddd;background:#fff;border-radius:3px;cursor:pointer;}
|
||||
.pi-btn-group button.active{background:#4B49AC;color:#fff;border-color:#4B49AC;}
|
||||
|
||||
/* Hierarchy */
|
||||
.ht-node{padding:1px 4px;cursor:pointer;border-radius:3px;display:inline-flex;align-items:center;gap:3px;}
|
||||
.ht-node:hover{background:#f0eeff;}
|
||||
.ht-node.active{background:#4B49AC;color:#fff;}
|
||||
.ht-children{padding-left:14px;border-left:1px solid #e0e0e0;margin-left:6px;}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// ===== TFT Component-Based UI Builder =====
|
||||
const COMP_DEFAULTS = {
|
||||
sidebar: {w:80,h:320,bg:'#1a1a2e',border:0,borderColor:'#333',radius:0,label:'Sidebar'},
|
||||
header: {w:480,h:40,bg:'#16213e',border:0,borderColor:'#333',radius:0,label:'Header'},
|
||||
card: {w:140,h:100,bg:'#1e1e30',border:1,borderColor:'#444',radius:8,label:'Card'},
|
||||
button: {w:90,h:32,bg:'#4B49AC',border:0,borderColor:'#666',radius:6,label:'Button',text:'Button',textColor:'#FFFFFF',fontSize:12,textAlign:'center'},
|
||||
toggle: {w:44,h:22,bg:'#34C759',border:0,borderColor:'transparent',radius:11,label:'Toggle',toggleState:true},
|
||||
progressbar: {w:120,h:10,bg:'#333',border:0,borderColor:'transparent',radius:5,label:'Progress',value:65,barColor:'#4B49AC'},
|
||||
datavalue: {w:80,h:36,bg:'transparent',border:0,borderColor:'transparent',radius:0,label:'DataValue',text:'24.5°C',textColor:'#00FF88',fontSize:18,textAlign:'center'},
|
||||
label: {w:100,h:20,bg:'transparent',border:0,borderColor:'transparent',radius:0,label:'Label',text:'Label Text',textColor:'#CCCCCC',fontSize:12,textAlign:'left'},
|
||||
icon: {w:28,h:28,bg:'transparent',border:0,borderColor:'transparent',radius:0,label:'Icon',icon:'★',textColor:'#FFD700',fontSize:18,textAlign:'center'}
|
||||
};
|
||||
|
||||
const CONTAINERS = ['sidebar','header','card'];
|
||||
|
||||
class TFTBuilder {
|
||||
constructor(){
|
||||
this.screens=[{name:'Screen 1',components:[]}];
|
||||
this.currentScreen=0;
|
||||
this.selectedId=null;
|
||||
this.idCounter=1;
|
||||
this.history=[];
|
||||
this.historyIdx=-1;
|
||||
this.designId=0;
|
||||
this.orientation='landscape';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init(){
|
||||
this.bindDragDrop();
|
||||
this.renderScreenTabs();
|
||||
this.renderHierarchy();
|
||||
this.pushHistory();
|
||||
this.loadList();
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
|
||||
if(e.key==='Delete'||e.key==='Backspace')this.deleteSelected();
|
||||
if(e.ctrlKey&&e.key==='z'){e.preventDefault();this.undo();}
|
||||
if(e.ctrlKey&&e.key==='y'){e.preventDefault();this.redo();}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== ORIENTATION =====
|
||||
setOrientation(o){
|
||||
this.orientation=o;
|
||||
const f=document.getElementById('canvasFrame');
|
||||
if(o==='landscape'){f.style.width='480px';f.style.height='320px';}
|
||||
else{f.style.width='320px';f.style.height='480px';}
|
||||
this.renderCanvas();
|
||||
}
|
||||
getW(){return this.orientation==='landscape'?480:320;}
|
||||
getH(){return this.orientation==='landscape'?320:480;}
|
||||
|
||||
// ===== DRAG & DROP FROM LIBRARY =====
|
||||
bindDragDrop(){
|
||||
document.querySelectorAll('.clib-item').forEach(el=>{
|
||||
el.addEventListener('dragstart',e=>{
|
||||
e.dataTransfer.setData('newComp',el.dataset.comp);
|
||||
e.dataTransfer.effectAllowed='copy';
|
||||
});
|
||||
});
|
||||
const canvas=document.getElementById('tftCanvas');
|
||||
canvas.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';});
|
||||
canvas.addEventListener('drop',e=>{
|
||||
e.preventDefault();
|
||||
const compType=e.dataTransfer.getData('newComp');
|
||||
if(!compType)return;
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
const x=Math.max(0,Math.round((e.clientX-rect.left)/5)*5);
|
||||
const y=Math.max(0,Math.round((e.clientY-rect.top)/5)*5);
|
||||
// Check if dropped on a container
|
||||
let parentId=null;
|
||||
const target=document.elementFromPoint(e.clientX,e.clientY);
|
||||
if(target){
|
||||
const parentEl=target.closest('.tft-comp[data-container="true"]');
|
||||
if(parentEl)parentId=parentEl.dataset.id;
|
||||
}
|
||||
this.addComponent(compType,x,y,parentId);
|
||||
});
|
||||
canvas.addEventListener('click',e=>{
|
||||
if(e.target===canvas||e.target.id==='tftCanvas'){this.select(null);}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== COMPONENT MANAGEMENT =====
|
||||
addComponent(type,x=10,y=10,parentId=null){
|
||||
const d=JSON.parse(JSON.stringify(COMP_DEFAULTS[type]));
|
||||
const id='c'+this.idCounter++;
|
||||
const comp={id,type,x,y,...d,padding:4,children:[],interactions:{event:'none',action:'none',target:'',payload:''}};
|
||||
if(parentId){
|
||||
const parent=this.findComp(this.currentComps(),parentId);
|
||||
if(parent){
|
||||
// Position relative to parent
|
||||
const pr=this.getAbsPos(parentId);
|
||||
comp.x=Math.max(0,x-(pr?.x||0));
|
||||
comp.y=Math.max(0,y-(pr?.y||0));
|
||||
parent.children.push(comp);
|
||||
}else{this.currentComps().push(comp);}
|
||||
}else{this.currentComps().push(comp);}
|
||||
this.pushHistory();
|
||||
this.renderCanvas();
|
||||
this.select(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
currentComps(){return this.screens[this.currentScreen].components;}
|
||||
|
||||
findComp(arr,id){
|
||||
for(const c of arr){
|
||||
if(c.id===id)return c;
|
||||
if(c.children){const f=this.findComp(c.children,id);if(f)return f;}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findParent(arr,id,parent=null){
|
||||
for(const c of arr){
|
||||
if(c.id===id)return{parent,list:arr};
|
||||
if(c.children){const f=this.findParent(c.children,id,c);if(f)return f;}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
removeComp(id){
|
||||
const info=this.findParent(this.currentComps(),id);
|
||||
if(!info)return;
|
||||
const idx=info.list.findIndex(c=>c.id===id);
|
||||
if(idx>=0)info.list.splice(idx,1);
|
||||
}
|
||||
|
||||
getAbsPos(id){
|
||||
// Walk up hierarchy to get absolute position
|
||||
const walk=(arr,tx,ty)=>{
|
||||
for(const c of arr){
|
||||
const ax=tx+c.x, ay=ty+c.y;
|
||||
if(c.id===id)return{x:ax,y:ay};
|
||||
if(c.children){const r=walk(c.children,ax,ay);if(r)return r;}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(this.currentComps(),0,0);
|
||||
}
|
||||
|
||||
deleteSelected(){
|
||||
if(!this.selectedId)return;
|
||||
this.removeComp(this.selectedId);
|
||||
this.selectedId=null;
|
||||
this.pushHistory();
|
||||
this.renderCanvas();
|
||||
this.renderHierarchy();
|
||||
this.clearProps();
|
||||
}
|
||||
|
||||
// ===== SELECTION =====
|
||||
select(id){
|
||||
this.selectedId=id;
|
||||
document.querySelectorAll('.tft-comp').forEach(el=>el.classList.remove('selected'));
|
||||
if(id){
|
||||
const el=document.querySelector(`.tft-comp[data-id="${id}"]`);
|
||||
if(el)el.classList.add('selected');
|
||||
this.renderProps();
|
||||
}else{this.clearProps();}
|
||||
this.renderHierarchy();
|
||||
}
|
||||
|
||||
clearProps(){
|
||||
document.getElementById('propsContent').innerHTML='<p class="text-muted text-center mt-4" style="font-size:12px;">Select a component<br>to edit properties</p>';
|
||||
}
|
||||
|
||||
// ===== RENDER CANVAS =====
|
||||
renderCanvas(){
|
||||
const canvas=document.getElementById('tftCanvas');
|
||||
canvas.innerHTML='';
|
||||
this.renderComps(this.currentComps(),canvas,0,0);
|
||||
}
|
||||
|
||||
renderComps(comps,parentEl,offX,offY){
|
||||
comps.forEach(c=>{
|
||||
const el=document.createElement('div');
|
||||
el.className='tft-comp'+(c.id===this.selectedId?' selected':'');
|
||||
el.dataset.id=c.id;
|
||||
el.dataset.container=CONTAINERS.includes(c.type)?'true':'false';
|
||||
el.style.left=c.x+'px';
|
||||
el.style.top=c.y+'px';
|
||||
el.style.width=c.w+'px';
|
||||
el.style.height=c.h+'px';
|
||||
el.style.backgroundColor=c.bg||'transparent';
|
||||
el.style.borderRadius=(c.radius||0)+'px';
|
||||
if(c.border>0)el.style.border=c.border+'px solid '+(c.borderColor||'#444');
|
||||
el.style.padding=(c.padding||0)+'px';
|
||||
el.style.overflow='hidden';
|
||||
|
||||
// Component-specific inner render
|
||||
el.innerHTML=this.renderCompInner(c);
|
||||
|
||||
// Label
|
||||
const lbl=document.createElement('div');
|
||||
lbl.className='tft-comp-label';
|
||||
lbl.textContent=c.type;
|
||||
el.appendChild(lbl);
|
||||
|
||||
// Resize handle
|
||||
const rh=document.createElement('div');
|
||||
rh.className='resize-handle';
|
||||
el.appendChild(rh);
|
||||
|
||||
// Events
|
||||
el.addEventListener('mousedown',e=>{
|
||||
if(e.target.classList.contains('resize-handle')){this.startResize(e,c);return;}
|
||||
e.stopPropagation();
|
||||
this.select(c.id);
|
||||
this.startDrag(e,c);
|
||||
});
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
// Render children inside this component
|
||||
if(c.children&&c.children.length){
|
||||
this.renderComps(c.children,el,0,0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderCompInner(c){
|
||||
switch(c.type){
|
||||
case 'button':
|
||||
return `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:${c.textAlign==='left'?'flex-start':c.textAlign==='right'?'flex-end':'center'};color:${c.textColor||'#fff'};font-size:${c.fontSize||12}px;pointer-events:none;font-family:Arial,sans-serif;">${c.text||'Button'}</div>`;
|
||||
case 'label':
|
||||
return `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:${c.textAlign==='left'?'flex-start':c.textAlign==='right'?'flex-end':'center'};color:${c.textColor||'#ccc'};font-size:${c.fontSize||12}px;pointer-events:none;font-family:Arial,sans-serif;overflow:hidden;white-space:nowrap;">${c.text||''}</div>`;
|
||||
case 'datavalue':
|
||||
return `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:${c.textAlign||'center'};color:${c.textColor||'#0f8'};font-size:${c.fontSize||18}px;font-weight:700;pointer-events:none;font-family:'Courier New',monospace;">${c.text||'0'}</div>`;
|
||||
case 'icon':
|
||||
return `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:${c.textColor||'#FFD700'};font-size:${c.fontSize||18}px;pointer-events:none;">${c.icon||'★'}</div>`;
|
||||
case 'toggle':
|
||||
const on=c.toggleState!==false;
|
||||
return `<div style="width:100%;height:100%;border-radius:${c.radius||11}px;background:${on?'#34C759':'#555'};position:relative;pointer-events:none;"><div style="position:absolute;top:2px;${on?'right:2px':'left:2px'};width:${c.h-4}px;height:${c.h-4}px;border-radius:50%;background:#fff;"></div></div>`;
|
||||
case 'progressbar':
|
||||
const pct=c.value||0;
|
||||
return `<div style="width:100%;height:100%;border-radius:${c.radius||5}px;background:${c.bg||'#333'};overflow:hidden;pointer-events:none;"><div style="width:${pct}%;height:100%;border-radius:${c.radius||5}px;background:${c.barColor||'#4B49AC'};"></div></div>`;
|
||||
case 'sidebar':
|
||||
case 'header':
|
||||
case 'card':
|
||||
return '';// containers render children
|
||||
default:return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DRAG (MOVE) =====
|
||||
startDrag(e,comp){
|
||||
e.preventDefault();
|
||||
const startX=e.clientX,startY=e.clientY;
|
||||
const origX=comp.x,origY=comp.y;
|
||||
const move=ev=>{
|
||||
comp.x=Math.max(0,Math.round((origX+(ev.clientX-startX))/5)*5);
|
||||
comp.y=Math.max(0,Math.round((origY+(ev.clientY-startY))/5)*5);
|
||||
const el=document.querySelector(`.tft-comp[data-id="${comp.id}"]`);
|
||||
if(el){el.style.left=comp.x+'px';el.style.top=comp.y+'px';}
|
||||
if(this.selectedId===comp.id)this.updatePropXY(comp);
|
||||
};
|
||||
const up=()=>{
|
||||
document.removeEventListener('mousemove',move);
|
||||
document.removeEventListener('mouseup',up);
|
||||
this.pushHistory();
|
||||
};
|
||||
document.addEventListener('mousemove',move);
|
||||
document.addEventListener('mouseup',up);
|
||||
}
|
||||
|
||||
// ===== RESIZE =====
|
||||
startResize(e,comp){
|
||||
e.preventDefault();e.stopPropagation();
|
||||
const startX=e.clientX,startY=e.clientY;
|
||||
const origW=comp.w,origH=comp.h;
|
||||
const move=ev=>{
|
||||
comp.w=Math.max(20,Math.round((origW+(ev.clientX-startX))/5)*5);
|
||||
comp.h=Math.max(10,Math.round((origH+(ev.clientY-startY))/5)*5);
|
||||
const el=document.querySelector(`.tft-comp[data-id="${comp.id}"]`);
|
||||
if(el){el.style.width=comp.w+'px';el.style.height=comp.h+'px';el.innerHTML=this.renderCompInner(comp);
|
||||
const lbl=document.createElement('div');lbl.className='tft-comp-label';lbl.textContent=comp.type;el.appendChild(lbl);
|
||||
const rh=document.createElement('div');rh.className='resize-handle';el.appendChild(rh);}
|
||||
if(this.selectedId===comp.id)this.updatePropWH(comp);
|
||||
};
|
||||
const up=()=>{
|
||||
document.removeEventListener('mousemove',move);
|
||||
document.removeEventListener('mouseup',up);
|
||||
this.pushHistory();
|
||||
this.renderCanvas();
|
||||
};
|
||||
document.addEventListener('mousemove',move);
|
||||
document.addEventListener('mouseup',up);
|
||||
}
|
||||
|
||||
updatePropXY(c){const ex=document.getElementById('pi_x'),ey=document.getElementById('pi_y');if(ex)ex.value=c.x;if(ey)ey.value=c.y;}
|
||||
updatePropWH(c){const ew=document.getElementById('pi_w'),eh=document.getElementById('pi_h');if(ew)ew.value=c.w;if(eh)eh.value=c.h;}
|
||||
|
||||
// ===== PROPERTY INSPECTOR =====
|
||||
renderProps(){
|
||||
const c=this.findComp(this.currentComps(),this.selectedId);
|
||||
if(!c){this.clearProps();return;}
|
||||
const isText=['button','label','datavalue','icon'].includes(c.type);
|
||||
const isContainer=CONTAINERS.includes(c.type);
|
||||
const rgb565=hexToRGB565(c.bg||'#1e2223');
|
||||
|
||||
let h=`<div class="pi-tabs">
|
||||
<button class="pi-tab active" onclick="TFT.piTab(this,0)">Layout</button>
|
||||
<button class="pi-tab" onclick="TFT.piTab(this,1)">Style</button>
|
||||
${isText?'<button class="pi-tab" onclick="TFT.piTab(this,2)">Text</button>':''}
|
||||
<button class="pi-tab" onclick="TFT.piTab(this,3)">Actions</button>
|
||||
</div>`;
|
||||
|
||||
// === TAB 0: Layout ===
|
||||
h+=`<div class="pi-panel" id="piPanel0">`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Component</div>
|
||||
<div style="font-size:12px;font-weight:600;color:#4B49AC;text-transform:capitalize;margin-bottom:4px;">${c.type}</div>
|
||||
<div class="pi-row"><label>ID</label><input type="text" value="${c.id}" disabled style="font-size:10px;color:#888;"></div>
|
||||
<div class="pi-row"><label>Name</label><input type="text" value="${c.label||''}" onchange="TFT.setProp('label',this.value)"></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Position (px)</div>
|
||||
<div class="pi-row"><label>X</label><input type="number" id="pi_x" value="${c.x}" onchange="TFT.setProp('x',+this.value)" step="5">
|
||||
<label>Y</label><input type="number" id="pi_y" value="${c.y}" onchange="TFT.setProp('y',+this.value)" step="5"></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Size (px)</div>
|
||||
<div class="pi-row"><label>W</label><input type="number" id="pi_w" value="${c.w}" onchange="TFT.setProp('w',+this.value)" step="5">
|
||||
<label>H</label><input type="number" id="pi_h" value="${c.h}" onchange="TFT.setProp('h',+this.value)" step="5"></div>
|
||||
</div>`;
|
||||
if(isContainer){
|
||||
h+=`<div class="pi-section"><div class="pi-title">Padding (px)</div>
|
||||
<div class="pi-row"><label>All</label><input type="number" value="${c.padding||0}" onchange="TFT.setProp('padding',+this.value)" min="0" max="50"></div>
|
||||
</div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
|
||||
// === TAB 1: Style ===
|
||||
h+=`<div class="pi-panel" id="piPanel1" style="display:none;">`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Background</div>
|
||||
<div class="pi-row"><label>Color</label><input type="color" value="${c.bg||'#1e2223'}" onchange="TFT.setProp('bg',this.value)"><input type="text" value="${c.bg||''}" style="width:80px;font-size:10px;" onchange="TFT.setProp('bg',this.value)"></div>
|
||||
<div class="rgb565-badge">RGB565: 0x${rgb565.toString(16).toUpperCase().padStart(4,'0')}</div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Border Radius</div>
|
||||
<div class="pi-row"><input type="range" min="0" max="50" value="${c.radius||0}" oninput="TFT.setProp('radius',+this.value);this.nextElementSibling.value=this.value"><input type="number" value="${c.radius||0}" style="width:45px;" onchange="TFT.setProp('radius',+this.value)"></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Border</div>
|
||||
<div class="pi-row"><label>Width</label><input type="number" value="${c.border||0}" onchange="TFT.setProp('border',+this.value)" min="0" max="10"></div>
|
||||
<div class="pi-row"><label>Color</label><input type="color" value="${c.borderColor||'#444444'}" onchange="TFT.setProp('borderColor',this.value)"><input type="text" value="${c.borderColor||''}" style="width:80px;font-size:10px;" onchange="TFT.setProp('borderColor',this.value)"></div>
|
||||
</div>`;
|
||||
if(c.type==='progressbar'){
|
||||
h+=`<div class="pi-section"><div class="pi-title">Progress Bar</div>
|
||||
<div class="pi-row"><label>Value</label><input type="range" min="0" max="100" value="${c.value||0}" oninput="TFT.setProp('value',+this.value);this.nextElementSibling.value=this.value"><input type="number" value="${c.value||0}" style="width:45px;" onchange="TFT.setProp('value',+this.value)"></div>
|
||||
<div class="pi-row"><label>Bar</label><input type="color" value="${c.barColor||'#4B49AC'}" onchange="TFT.setProp('barColor',this.value)"></div>
|
||||
</div>`;
|
||||
}
|
||||
if(c.type==='toggle'){
|
||||
h+=`<div class="pi-section"><div class="pi-title">Toggle</div>
|
||||
<div class="pi-row"><label>State</label><select onchange="TFT.setProp('toggleState',this.value==='on')"><option value="on" ${c.toggleState!==false?'selected':''}>ON</option><option value="off" ${c.toggleState===false?'selected':''}>OFF</option></select></div>
|
||||
</div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
|
||||
// === TAB 2: Text ===
|
||||
if(isText){
|
||||
h+=`<div class="pi-panel" id="piPanel2" style="display:none;">`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">${c.type==='icon'?'Icon':'Text Content'}</div>
|
||||
<div class="pi-row"><input type="text" value="${(c.text||c.icon||'').replace(/"/g,'"')}" onchange="TFT.setProp('${c.type==='icon'?'icon':'text'}',this.value)"></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Font</div>
|
||||
<div class="pi-row"><label>Size</label><input type="number" value="${c.fontSize||12}" onchange="TFT.setProp('fontSize',+this.value)" min="8" max="48"></div>
|
||||
<div class="pi-row"><label>Color</label><input type="color" value="${c.textColor||'#FFFFFF'}" onchange="TFT.setProp('textColor',this.value)"><input type="text" value="${c.textColor||''}" style="width:80px;font-size:10px;" onchange="TFT.setProp('textColor',this.value)"></div>
|
||||
<div class="rgb565-badge">RGB565: 0x${hexToRGB565(c.textColor||'#FFFFFF').toString(16).toUpperCase().padStart(4,'0')}</div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Alignment</div>
|
||||
<div class="pi-btn-group">
|
||||
<button class="${c.textAlign==='left'?'active':''}" onclick="TFT.setProp('textAlign','left')">Left</button>
|
||||
<button class="${c.textAlign==='center'?'active':''}" onclick="TFT.setProp('textAlign','center')">Center</button>
|
||||
<button class="${c.textAlign==='right'?'active':''}" onclick="TFT.setProp('textAlign','right')">Right</button>
|
||||
</div>
|
||||
</div>`;
|
||||
h+=`</div>`;
|
||||
}
|
||||
|
||||
// === TAB 3: Interactions ===
|
||||
const inter=c.interactions||{event:'none',action:'none',target:'',payload:''};
|
||||
h+=`<div class="pi-panel" id="piPanel3" style="display:none;">`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Event</div>
|
||||
<div class="pi-row"><select onchange="TFT.setInteraction('event',this.value)"><option value="none" ${inter.event==='none'?'selected':''}>None</option><option value="onTouch" ${inter.event==='onTouch'?'selected':''}>onTouch</option></select></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Action</div>
|
||||
<div class="pi-row"><select onchange="TFT.setInteraction('action',this.value)"><option value="none" ${inter.action==='none'?'selected':''}>None</option><option value="navigate" ${inter.action==='navigate'?'selected':''}>Maps to Page...</option><option value="command" ${inter.action==='command'?'selected':''}>Send Command...</option></select></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Target / Page</div>
|
||||
<div class="pi-row"><input type="text" value="${inter.target||''}" onchange="TFT.setInteraction('target',this.value)" placeholder="Screen name or page index"></div>
|
||||
</div>`;
|
||||
h+=`<div class="pi-section"><div class="pi-title">Payload / Command</div>
|
||||
<div class="pi-row"><input type="text" value="${inter.payload||''}" onchange="TFT.setInteraction('payload',this.value)" placeholder='e.g. {"relay":1,"state":"ON"}'></div>
|
||||
</div>`;
|
||||
h+=`</div>`;
|
||||
|
||||
document.getElementById('propsContent').innerHTML=h;
|
||||
}
|
||||
|
||||
piTab(btn,idx){
|
||||
btn.parentElement.querySelectorAll('.pi-tab').forEach(b=>b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('.pi-panel').forEach((p,i)=>p.style.display=i===idx?'block':'none');
|
||||
}
|
||||
|
||||
setProp(key,val){
|
||||
const c=this.findComp(this.currentComps(),this.selectedId);
|
||||
if(!c)return;
|
||||
c[key]=val;
|
||||
this.renderCanvas();
|
||||
this.select(c.id);
|
||||
}
|
||||
|
||||
setInteraction(key,val){
|
||||
const c=this.findComp(this.currentComps(),this.selectedId);
|
||||
if(!c)return;
|
||||
if(!c.interactions)c.interactions={event:'none',action:'none',target:'',payload:''};
|
||||
c.interactions[key]=val;
|
||||
}
|
||||
|
||||
// ===== HIERARCHY TREE =====
|
||||
renderHierarchy(){
|
||||
const el=document.getElementById('hierarchyTree');
|
||||
const screenName=this.screens[this.currentScreen].name;
|
||||
let h=`<span class="ht-node" onclick="TFT.select(null)">📱 ${screenName}</span>`;
|
||||
h+=this.buildTree(this.currentComps());
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
buildTree(comps){
|
||||
if(!comps.length)return'';
|
||||
let h='<div class="ht-children">';
|
||||
comps.forEach(c=>{
|
||||
const icon=CONTAINERS.includes(c.type)?'📦':'⬡';
|
||||
h+=`<div><span class="ht-node ${c.id===this.selectedId?'active':''}" onclick="event.stopPropagation();TFT.select('${c.id}')">${icon} ${c.label||c.type}</span>`;
|
||||
if(c.children&&c.children.length)h+=this.buildTree(c.children);
|
||||
h+=`</div>`;
|
||||
});
|
||||
h+='</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
// ===== SCREEN MANAGEMENT =====
|
||||
renderScreenTabs(){
|
||||
let h='';
|
||||
this.screens.forEach((s,i)=>{
|
||||
h+=`<div class="screen-tab ${i===this.currentScreen?'active':''}" onclick="TFT.switchScreen(${i})">
|
||||
<span ondblclick="TFT.renameScreen(${i})">${s.name}</span>
|
||||
${this.screens.length>1?`<span style="margin-left:4px;cursor:pointer;opacity:.5;font-size:10px;" onclick="event.stopPropagation();TFT.deleteScreen(${i})">✕</span>`:''}
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('screenTabs').innerHTML=h;
|
||||
}
|
||||
|
||||
switchScreen(idx){
|
||||
this.currentScreen=idx;
|
||||
this.selectedId=null;
|
||||
this.renderCanvas();
|
||||
this.renderScreenTabs();
|
||||
this.renderHierarchy();
|
||||
this.clearProps();
|
||||
}
|
||||
|
||||
addScreen(){
|
||||
this.screens.push({name:'Screen '+(this.screens.length+1),components:[]});
|
||||
this.switchScreen(this.screens.length-1);
|
||||
this.pushHistory();
|
||||
}
|
||||
|
||||
deleteScreen(idx){
|
||||
if(this.screens.length<=1)return;
|
||||
this.screens.splice(idx,1);
|
||||
if(this.currentScreen>=this.screens.length)this.currentScreen=this.screens.length-1;
|
||||
this.switchScreen(this.currentScreen);
|
||||
this.pushHistory();
|
||||
}
|
||||
|
||||
renameScreen(idx){
|
||||
const name=prompt('Screen name:',this.screens[idx].name);
|
||||
if(name){this.screens[idx].name=name;this.renderScreenTabs();}
|
||||
}
|
||||
|
||||
// ===== HISTORY =====
|
||||
pushHistory(){
|
||||
const state=JSON.stringify(this.screens);
|
||||
this.history=this.history.slice(0,this.historyIdx+1);
|
||||
this.history.push(state);
|
||||
if(this.history.length>30)this.history.shift();
|
||||
this.historyIdx=this.history.length-1;
|
||||
}
|
||||
|
||||
undo(){if(this.historyIdx<=0)return;this.historyIdx--;this.restoreHistory();}
|
||||
redo(){if(this.historyIdx>=this.history.length-1)return;this.historyIdx++;this.restoreHistory();}
|
||||
|
||||
restoreHistory(){
|
||||
this.screens=JSON.parse(this.history[this.historyIdx]);
|
||||
if(this.currentScreen>=this.screens.length)this.currentScreen=0;
|
||||
this.selectedId=null;
|
||||
this.renderCanvas();
|
||||
this.renderScreenTabs();
|
||||
this.renderHierarchy();
|
||||
this.clearProps();
|
||||
}
|
||||
|
||||
// ===== EXPORT JSON =====
|
||||
getDesignJSON(){
|
||||
return {projectName:document.getElementById('projectName').value,orientation:this.orientation,screens:this.screens};
|
||||
}
|
||||
|
||||
// ===== API =====
|
||||
save(){
|
||||
const data=this.getDesignJSON();
|
||||
const fd=new FormData();
|
||||
fd.append('action','save');
|
||||
fd.append('id',this.designId||0);
|
||||
fd.append('name',data.projectName||'Untitled');
|
||||
fd.append('design_json',JSON.stringify(data));
|
||||
fetch('api_sync.php',{method:'POST',body:fd}).then(r=>r.json()).then(res=>{
|
||||
if(res.status==='success'){this.designId=res.id;alert('✅ Saved!');this.loadList();}
|
||||
else alert('❌ '+res.message);
|
||||
});
|
||||
}
|
||||
|
||||
loadList(){
|
||||
fetch('api_sync.php?action=list').then(r=>r.json()).then(res=>{
|
||||
if(res.status==='success'){
|
||||
let opts='<option value="0">-- New --</option>';
|
||||
res.data.forEach(d=>{opts+=`<option value="${d.id}">${d.name}</option>`;});
|
||||
const sel=document.getElementById('savedDesigns');sel.innerHTML=opts;
|
||||
if(this.designId)sel.value=this.designId;
|
||||
sel.onchange=function(){if(this.value!=='0')TFT.loadDesign(this.value);};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadDesign(id){
|
||||
fetch('api_sync.php?action=load&id='+id).then(r=>r.json()).then(res=>{
|
||||
if(res.status!=='success')return;
|
||||
this.designId=res.data.id;
|
||||
document.getElementById('projectName').value=res.data.name;
|
||||
const d=JSON.parse(res.data.design_json);
|
||||
this.screens=d.screens||[{name:'Screen 1',components:[]}];
|
||||
this.orientation=d.orientation||'landscape';
|
||||
document.getElementById('orientationSelect').value=this.orientation;
|
||||
this.setOrientation(this.orientation);
|
||||
this.currentScreen=0;
|
||||
this.selectedId=null;
|
||||
this.renderCanvas();
|
||||
this.renderScreenTabs();
|
||||
this.renderHierarchy();
|
||||
this.clearProps();
|
||||
this.pushHistory();
|
||||
});
|
||||
}
|
||||
|
||||
exportCpp(){
|
||||
const data=this.getDesignJSON();
|
||||
const fd=new FormData();
|
||||
fd.append('action','export_cpp');
|
||||
fd.append('design_json',JSON.stringify(data));
|
||||
fetch('api_sync.php',{method:'POST',body:fd}).then(r=>r.json()).then(res=>{
|
||||
if(res.status==='success'){
|
||||
const blob=new Blob([res.code],{type:'text/plain'});
|
||||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||||
a.download=(data.projectName||'tft_ui')+'.ino';a.click();
|
||||
}else alert('❌ '+res.message);
|
||||
});
|
||||
}
|
||||
|
||||
sync(){this.save();alert('✅ Design synced! ESP32 will pick up changes.');}
|
||||
|
||||
pullESP(){
|
||||
fetch('api_sync.php?action=latest').then(r=>r.json()).then(res=>{
|
||||
if(res.status==='success'&&res.elements&&res.elements.length>0){
|
||||
// Convert flat elements to component structure
|
||||
this.screens[this.currentScreen].components=res.elements.map((el,i)=>({
|
||||
id:'c'+(this.idCounter++),type:el.type||'card',x:el.x||0,y:el.y||0,w:el.w||100,h:el.h||50,
|
||||
bg:el.fill||'#1e1e30',border:el.strokeWidth||0,borderColor:el.stroke||'#444',
|
||||
radius:el.radius||0,padding:4,label:el.type||'Imported',text:el.text||'',
|
||||
textColor:el.textColor||'#fff',fontSize:el.fontSize||12,textAlign:'center',
|
||||
children:[],interactions:{event:'none',action:'none',target:'',payload:''}
|
||||
}));
|
||||
this.renderCanvas();this.renderHierarchy();this.pushHistory();
|
||||
alert('✅ Pulled from ESP32!');
|
||||
}else alert('⚠️ No elements found.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === RGB565 Helper ===
|
||||
function hexToRGB565(hex){
|
||||
if(!hex||hex==='transparent')return 0;
|
||||
hex=hex.replace('#','');
|
||||
if(hex.length===3)hex=hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
const r=parseInt(hex.substring(0,2),16);
|
||||
const g=parseInt(hex.substring(2,4),16);
|
||||
const b=parseInt(hex.substring(4,6),16);
|
||||
return((r&0xF8)<<8)|((g&0xFC)<<3)|(b>>3);
|
||||
}
|
||||
|
||||
// Init
|
||||
let TFT;
|
||||
document.addEventListener('DOMContentLoaded',()=>{TFT=new TFTBuilder();});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DFD Sistem Identia</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
size: portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1, h2 {
|
||||
text-align: center;
|
||||
color: #000;
|
||||
}
|
||||
p {
|
||||
color: #000;
|
||||
}
|
||||
ul {
|
||||
color: #000;
|
||||
background: #fff;
|
||||
padding: 15px 30px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Gambar Data Flow Diagram (DFD) - Identia System</h1>
|
||||
<p>Simbol disesuaikan dengan standar DFD Campuran (Gane/Sarson untuk Data Store):</p>
|
||||
<ul>
|
||||
<li><b>Entitas Eksternal:</b> Persegi Panjang</li>
|
||||
<li><b>Proses:</b> Lingkaran (Satu Garis)</li>
|
||||
<li><b>Data Store:</b> Tabel Terbuka Kanan (Gane/Sarson)</li>
|
||||
<li><b>Aliran Data:</b> Tanda Panah</li>
|
||||
</ul>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. DFD Level 0 (Context Diagram)</h2>
|
||||
<div class="mermaid">
|
||||
flowchart TD
|
||||
classDef entity fill:#fff,stroke:#000,stroke-width:2px,color:#000,font-weight:bold
|
||||
classDef process fill:#fff,stroke:#000,stroke-width:2px,color:#000,font-weight:bold
|
||||
|
||||
Admin["Admin (Dashboard)"]:::entity
|
||||
Occupant["Penghuni"]:::entity
|
||||
ESP["Alat Scan Pintu (ESP32)"]:::entity
|
||||
|
||||
Sistem(("0. Sistem Identia")):::process
|
||||
|
||||
Admin -->|"Data Profil & Pengaturan"| Sistem
|
||||
Sistem -->|"Laporan Kehadiran & Notifikasi"| Admin
|
||||
|
||||
Occupant -->|"Pindai Sidik Jari / Wajah"| Sistem
|
||||
Sistem -->|"Status Pintu (Terbuka/Ditolak)"| Occupant
|
||||
|
||||
ESP -->|"Kirim Log Identitas Scan"| Sistem
|
||||
Sistem -->|"Perintah Buka Pintu"| ESP
|
||||
|
||||
linkStyle default stroke:#000,stroke-width:2px,color:#000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. DFD Level 1 (Proses Utama)</h2>
|
||||
<div class="mermaid">
|
||||
flowchart TD
|
||||
classDef entity fill:#fff,stroke:#000,stroke-width:2px,color:#000,font-weight:bold
|
||||
classDef process fill:#fff,stroke:#000,stroke-width:2px,color:#000,font-weight:bold
|
||||
classDef datastore fill:transparent,stroke:none,color:#000,font-weight:bold
|
||||
|
||||
%% Entitas Eksternal
|
||||
Admin["Admin (Dashboard)"]:::entity
|
||||
Occupant["Penghuni"]:::entity
|
||||
ESP["Alat Scan Pintu (ESP32)"]:::entity
|
||||
|
||||
%% Proses Inti
|
||||
P1(("1. Pendataan Penghuni")):::process
|
||||
P2(("2. Autentikasi Akses Pintu")):::process
|
||||
P3(("3. Pencatatan Kehadiran")):::process
|
||||
P4(("4. Penyiaran Notifikasi")):::process
|
||||
|
||||
%% Data Store
|
||||
D1["<div style='display:flex; border-top:2px solid #000; border-bottom:2px solid #000; border-left:2px solid #000; border-right:none;'><div style='border-right:2px solid #000; padding:8px;'>D1</div><div style='padding:8px 15px;'>Data Profil Penghuni</div></div>"]:::datastore
|
||||
D2["<div style='display:flex; border-top:2px solid #000; border-bottom:2px solid #000; border-left:2px solid #000; border-right:none;'><div style='border-right:2px solid #000; padding:8px;'>D2</div><div style='padding:8px 15px;'>Data Riwayat Akses</div></div>"]:::datastore
|
||||
|
||||
%% 1. Alur Manajemen Data
|
||||
Admin -->|"Input Profil Baru / Ubah"| P1
|
||||
P1 -->|"Simpan Profil"| D1
|
||||
D1 -->|"Baca Daftar Profil"| P1
|
||||
P1 -->|"Tampil Data ke Dasbor"| Admin
|
||||
|
||||
%% 2. Alur Scanning Pintu
|
||||
Occupant -->|"Pindai Jari / Wajah"| ESP
|
||||
ESP -->|"Kirim Identitas Scan"| P2
|
||||
D1 -->|"Validasi Identitas"| P2
|
||||
P2 -->|"Status Buka Pintu"| ESP
|
||||
ESP -->|"Pintu Terbuka"| Occupant
|
||||
|
||||
%% 3. Alur Pencatatan Log
|
||||
P2 -->|"Kirim Status Keberhasilan"| P3
|
||||
P3 -->|"Catat Waktu Kehadiran"| D2
|
||||
D2 -->|"Baca Rekapitulasi"| P3
|
||||
P3 -->|"Tampil Laporan Absensi"| Admin
|
||||
|
||||
%% 4. Alur Pemberitahuan
|
||||
D2 -->|"Aktivitas Akses Masuk Baru"| P4
|
||||
P4 -->|"Kirim Pesan Pop-up (Realtime)"| Admin
|
||||
|
||||
linkStyle default stroke:#000,stroke-width:2px,color:#000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'base',
|
||||
securityLevel: 'loose',
|
||||
themeVariables: {
|
||||
lineColor: '#000000',
|
||||
textColor: '#000000',
|
||||
primaryColor: '#ffffff',
|
||||
primaryBorderColor: '#000000',
|
||||
edgeLabelBackground: '#ffffff'
|
||||
},
|
||||
flowchart: { htmlLabels: true }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
|||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
// The function actually applying the offset
|
||||
function offsetAnchor() {
|
||||
if (location.hash.length !== 0) {
|
||||
// window.scrollTo(window.scrollX, window.scrollY - 140);
|
||||
$("html").animate({ scrollTop: $(location.hash).offset().top - 160 }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Captures click events of all <a> elements with href starting with #
|
||||
$(document).on('click', 'a[href^="#"]', function(event) {
|
||||
// Click events are captured before hashchanges. Timeout
|
||||
// causes offsetAnchor to be called after the page jump.
|
||||
window.setTimeout(function() {
|
||||
offsetAnchor();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Set the offset when entering page with hash present in the url
|
||||
window.setTimeout(offsetAnchor, 0);
|
||||
});
|
||||
})(jQuery);
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(["status" => "error", "message" => "Method tidak diizinkan"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_POST['id_account']) || empty($_POST['id_account'])) {
|
||||
echo json_encode(["status" => "error", "message" => "ID Account hilang"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_POST['id_account'];
|
||||
$name = trim($_POST['name']);
|
||||
$username = trim($_POST['username']);
|
||||
|
||||
// Cek username bentrok
|
||||
$cek = $pdo->prepare("SELECT COUNT(*) FROM account WHERE username = ? AND id_account != ?");
|
||||
$cek->execute([$username, $id]);
|
||||
if ($cek->fetchColumn() > 0) {
|
||||
echo json_encode(["status" => "error", "message" => "Username sudah digunakan"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$updateFoto = false;
|
||||
$fotoPath = '';
|
||||
|
||||
if (isset($_FILES['foto']) && $_FILES['foto']['error'] === UPLOAD_ERR_OK) {
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
$ext = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($ext, $allowed)) {
|
||||
echo json_encode(["status" => "error", "message" => "Format foto tidak didukung"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/uploads/';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
$fotoName = 'acc_' . time() . '_' . random_int(1000, 9999) . '.' . $ext;
|
||||
$targetPath = $uploadDir . $fotoName;
|
||||
|
||||
if (move_uploaded_file($_FILES['foto']['tmp_name'], $targetPath)) {
|
||||
$fotoPath = 'uploads/' . $fotoName;
|
||||
$updateFoto = true;
|
||||
|
||||
// hapus foto lama
|
||||
$q = $pdo->prepare("SELECT foto FROM account WHERE id_account = ?");
|
||||
$q->execute([$id]);
|
||||
$oldFoto = $q->fetchColumn();
|
||||
if ($oldFoto && file_exists(__DIR__ . '/' . $oldFoto) && strpos($oldFoto, 'images/faces/') === false) {
|
||||
@unlink(__DIR__ . '/' . $oldFoto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$sql = "";
|
||||
$params = [];
|
||||
|
||||
// Jika user menginput password baru
|
||||
if (isset($_POST['password']) && trim($_POST['password']) !== '') {
|
||||
$newPass = trim($_POST['password']);
|
||||
$passHash = password_hash($newPass, PASSWORD_DEFAULT);
|
||||
|
||||
if ($updateFoto) {
|
||||
$sql = "UPDATE account SET name = ?, username = ?, password = ?, password_enc = ?, foto = ? WHERE id_account = ?";
|
||||
$params = [$name, $username, $passHash, $newPass, $fotoPath, $id];
|
||||
} else {
|
||||
$sql = "UPDATE account SET name = ?, username = ?, password = ?, password_enc = ? WHERE id_account = ?";
|
||||
$params = [$name, $username, $passHash, $newPass, $id];
|
||||
}
|
||||
} else {
|
||||
if ($updateFoto) {
|
||||
$sql = "UPDATE account SET name = ?, username = ?, foto = ? WHERE id_account = ?";
|
||||
$params = [$name, $username, $fotoPath, $id];
|
||||
} else {
|
||||
$sql = "UPDATE account SET name = ?, username = ? WHERE id_account = ?";
|
||||
$params = [$name, $username, $id];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
echo json_encode(["status" => "success"]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(["status" => "error", "message" => "Gagal mengubah: " . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,958 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
include 'run_background.php';
|
||||
|
||||
$token = isset($_GET['token']) ? trim($_GET['token']) : '';
|
||||
if (empty($token)) {
|
||||
header("Location: 404.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi Token
|
||||
$manifestPath = __DIR__ . '/manifest_edit_tokens.json';
|
||||
if (!file_exists($manifestPath)) {
|
||||
header("Location: 404.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$tokens = json_decode(file_get_contents($manifestPath), true) ?: [];
|
||||
if (!isset($tokens[$token])) {
|
||||
header("Location: 404.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$tokenData = $tokens[$token];
|
||||
if (time() > $tokenData['expires_at']) {
|
||||
unset($tokens[$token]);
|
||||
file_put_contents($manifestPath, json_encode($tokens, JSON_PRETTY_PRINT));
|
||||
header("Location: 404.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$id_occupant = $tokenData['id_occupant'];
|
||||
|
||||
// Ambil data occupant
|
||||
$stmt = $pdo->prepare("SELECT name, nik_nip, no_hp, foto FROM occupant WHERE id_occupant = ?");
|
||||
$stmt->execute([$id_occupant]);
|
||||
$occupant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$occupant) {
|
||||
header("Location: 404.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Proses jika form disubmit via AJAX
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
header('Content-Type: application/json');
|
||||
$name = trim($_POST['name']);
|
||||
$nik_nip = isset($_POST['nik_nip']) ? trim($_POST['nik_nip']) : '';
|
||||
|
||||
if (empty($name)) {
|
||||
echo json_encode(["status" => "error", "message" => "Name is required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$fotoPath = $occupant['foto']; // Default bawaan
|
||||
if (isset($_FILES['foto']) && $_FILES['foto']['error'] === UPLOAD_ERR_OK) {
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
$ext = strtolower(pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if (!in_array($ext, $allowed)) {
|
||||
echo json_encode(["status" => "error", "message" => "Photo format not supported"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_FILES['foto']['size'] > 5 * 1024 * 1024) {
|
||||
echo json_encode(["status" => "error", "message" => "Max photo size is 5MB"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$uploadDir = __DIR__ . '/uploads/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$fotoName = 'occ_' . time() . '_' . random_int(1000, 9999) . '.' . $ext;
|
||||
$targetPath = $uploadDir . $fotoName;
|
||||
|
||||
if (move_uploaded_file($_FILES['foto']['tmp_name'], $targetPath)) {
|
||||
$fotoPath = 'uploads/' . $fotoName;
|
||||
|
||||
// Hapus foto lama jika ada dan bukan bawaan sistem
|
||||
if (!empty($occupant['foto']) && file_exists(__DIR__ . '/' . $occupant['foto'])) {
|
||||
@unlink(__DIR__ . '/' . $occupant['foto']);
|
||||
}
|
||||
} else {
|
||||
echo json_encode(["status" => "error", "message" => "Failed to upload photo. Check folder permissions."]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle base64 foto_data from camera capture
|
||||
$fotoData = isset($_POST['foto_data']) ? $_POST['foto_data'] : '';
|
||||
if (!empty($fotoData) && strpos($fotoData, 'data:image') === 0) {
|
||||
$uploadDir = __DIR__ . '/uploads/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
$parts = explode(',', $fotoData, 2);
|
||||
if (count($parts) === 2) {
|
||||
$imgBinary = base64_decode($parts[1]);
|
||||
if ($imgBinary !== false) {
|
||||
$fotoName = 'occ_' . time() . '_' . random_int(1000, 9999) . '.jpg';
|
||||
$targetPath = $uploadDir . $fotoName;
|
||||
|
||||
if (file_put_contents($targetPath, $imgBinary)) {
|
||||
// Hapus foto lama
|
||||
if (!empty($occupant['foto']) && file_exists(__DIR__ . '/' . $occupant['foto'])) {
|
||||
@unlink(__DIR__ . '/' . $occupant['foto']);
|
||||
}
|
||||
$fotoPath = 'uploads/' . $fotoName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$stmtUpdate = $pdo->prepare("UPDATE occupant SET name = ?, nik_nip = ?, foto = ? WHERE id_occupant = ?");
|
||||
$stmtUpdate->execute([$name, $nik_nip, $fotoPath, $id_occupant]);
|
||||
|
||||
// Success, invalidate token dan update pesan telegram
|
||||
if (isset($tokenData['msg_id'])) {
|
||||
$botToken = "8184881871:AAFOz6uzIgxE7rk3WttcKKrr0DtcNGIt-Ho";
|
||||
$chatId = '';
|
||||
$tstmt = $pdo->prepare("SELECT telegram_id FROM occupant WHERE id_occupant = ?");
|
||||
$tstmt->execute([$id_occupant]);
|
||||
$tdata = $tstmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($tdata && $tdata['telegram_id']) {
|
||||
$chatId = $tdata['telegram_id'];
|
||||
@file_get_contents("https://api.telegram.org/bot$botToken/deleteMessage?chat_id=$chatId&message_id={$tokenData['msg_id']}");
|
||||
if (isset($tokenData['user_msg_id'])) {
|
||||
@file_get_contents("https://api.telegram.org/bot$botToken/deleteMessage?chat_id=$chatId&message_id={$tokenData['user_msg_id']}");
|
||||
}
|
||||
|
||||
// Send success message that auto-deletes after 5 seconds with live countdown
|
||||
$successLabel = "✅ Data updated successfully";
|
||||
$successText = "<b>" . $successLabel . "</b>";
|
||||
$successPost = ['chat_id' => $chatId, 'text' => $successText, 'parse_mode' => 'HTML'];
|
||||
$res = @file_get_contents("https://api.telegram.org/bot$botToken/sendMessage?" . http_build_query($successPost));
|
||||
$resData = json_decode($res, true);
|
||||
if (isset($resData['result']['message_id'])) {
|
||||
$successMsgId = $resData['result']['message_id'];
|
||||
runBackground(__DIR__ . '/auto_delete_quick.php', [$chatId, $successMsgId, $successLabel]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unset($tokens[$token]);
|
||||
file_put_contents($manifestPath, json_encode($tokens, JSON_PRETTY_PRINT));
|
||||
|
||||
echo json_encode(["status" => "success", "message" => "Data updated successfully!"]);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(["status" => "error", "message" => "Failed to save: " . $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, maximum-scale=1, user-scalable=0">
|
||||
<title>Profile Update - Identia</title>
|
||||
<!-- CSS & Plugins (mirip manage_occupant) -->
|
||||
<link rel="stylesheet" href="vendors/feather/feather.css">
|
||||
<link rel="stylesheet" href="vendors/ti-icons/css/themify-icons.css">
|
||||
<link rel="stylesheet" href="vendors/css/vendor.bundle.base.css">
|
||||
<link rel="stylesheet" href="css/vertical-layout-light/style.css">
|
||||
<link rel="stylesheet" href="vendors/mdi/css/materialdesignicons.min.css">
|
||||
<style>
|
||||
.auth .brand-logo img { width: 140px; max-width: 100%; }
|
||||
.auth-form-light { border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.custom-alert {
|
||||
position: fixed; top: 20px; right: 20px; background: #fff;
|
||||
border-left: 4px solid #fcd34d; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 15px 20px; border-radius: 4px; z-index: 9999;
|
||||
display: none; align-items: center; gap: 12px; min-width: 300px;
|
||||
max-width: 400px; animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
.custom-alert.error { border-left-color: #ef4444; }
|
||||
.custom-alert.success { border-left-color: #10b981; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
.custom-alert-icon { font-size: 24px; }
|
||||
.custom-alert.error .custom-alert-icon { color: #ef4444; }
|
||||
.custom-alert.success .custom-alert-icon { color: #10b981; }
|
||||
.custom-alert.warning .custom-alert-icon { color: #f59e0b; }
|
||||
.custom-alert-content { flex: 1; text-align: left; }
|
||||
.custom-alert-title { font-weight: bold; font-size: 14px; margin-bottom: 2px; color: #333; }
|
||||
.custom-alert-msg { font-size: 13px; color: #666; line-height: 1.4; }
|
||||
.custom-alert-close { cursor: pointer; color: #999; font-size: 18px; }
|
||||
.custom-alert-close:hover { color: #333; }
|
||||
.info-alert { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 10px 12px; font-size: 13px; color: #1e293b; }
|
||||
.submit-btn {
|
||||
background: var(--primary); color: #fff; width: 100%; padding: 16px; border-radius: 16px; font-size: 16px; font-weight: 700; border: none; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 10px;
|
||||
}
|
||||
.info-alert-icon { width: 22px; height: 22px; border-radius: 50%; background: #1d4ed8; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.info-alert-icon i { color: #e0ecff; font-size: 14px; line-height: 1; }
|
||||
body, .container-scroller, .page-body-wrapper, .content-wrapper { background-color: #f8f9fa !important; }
|
||||
@media (max-width: 480px) {
|
||||
.custom-alert { top: 10px; right: 10px; left: 10px; min-width: unset; max-width: unset; }
|
||||
}
|
||||
.success-checkmark { text-align: center; padding: 30px 10px; animation: scaleIn 0.5s ease; }
|
||||
@keyframes scaleIn { 0% { transform: scale(0.5); opacity: 0; } 50% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }
|
||||
|
||||
.foto-wrapper:hover .foto-circle { border-color: #4747a1 !important; }
|
||||
.foto-wrapper:active { transform: scale(0.95); }
|
||||
/* Session Timer Pill */
|
||||
.session-timer-pill {
|
||||
position: fixed; top: 12px; right: 12px; z-index: 10000;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border-radius: 30px;
|
||||
background: rgba(255,255,255,0.95); border: 1px solid #e2e8f0;
|
||||
font-size: 12px; font-weight: 700; color: #64748b;
|
||||
backdrop-filter: blur(8px); box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.session-timer-pill i { font-size: 16px; color: #f59e0b; }
|
||||
.session-timer-pill.urgent { background: #fef2f2; border-color: #fecaca; color: #dc2626; }
|
||||
.session-timer-pill.urgent i { color: #dc2626; }
|
||||
/* Session Expired Overlay */
|
||||
.session-expired-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 99999;
|
||||
background: #f8f9fa;
|
||||
flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center; padding: 40px 30px;
|
||||
animation: fadeInOverlay 0.4s ease;
|
||||
}
|
||||
@keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } }
|
||||
.expired-icon {
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: #fef2f2; border: 1px solid #fecaca;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.expired-icon i { font-size: 40px; color: #ef4444; }
|
||||
.session-expired-overlay h3 { font-size: 22px; font-weight: 800; color: #1e293b; margin-bottom: 8px; }
|
||||
.session-expired-overlay p { font-size: 14px; color: #64748b; line-height: 1.6; max-width: 300px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-scroller">
|
||||
<div class="container-fluid page-body-wrapper full-page-wrapper">
|
||||
<div class="content-wrapper d-flex align-items-center auth px-0" style="padding-top: 2rem; padding-bottom: 2rem; align-items: flex-start !important;">
|
||||
<div class="row w-100 mx-0">
|
||||
<div class="col-lg-4 mx-auto pe-0 ps-0">
|
||||
<div class="auth-form-light text-left py-4 px-4 px-sm-5 bg-white" style="min-height: 10vh; padding-bottom: 80px !important;">
|
||||
<div class="brand-logo mb-4 text-center">
|
||||
<img src="images/ko.png" alt="logo" style="width: 140px;">
|
||||
</div>
|
||||
|
||||
<h4 class="font-weight-bold mb-1" style="color: #1e293b;">Edit Profile</h4>
|
||||
<p class="font-weight-light text-muted mb-4" style="font-size: 13px;">Update your personal identity data</p>
|
||||
|
||||
<div class="info-alert mb-4">
|
||||
<div class="info-alert-icon"><i class="mdi mdi-information-variant"></i></div>
|
||||
<div class="info-alert-text" style="flex:1; font-size:12px;">
|
||||
<span>Phone Number (Telegram) cannot be changed on this page.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 1: Edit Data -->
|
||||
<div id="step1-data">
|
||||
<form class="pt-2" id="editForm">
|
||||
<input type="hidden" name="action" value="update_data">
|
||||
<div class="form-group mb-3">
|
||||
<label class="font-weight-bold text-dark" style="font-size: 12px; margin-bottom: 6px;">Full Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" id="name" value="<?= htmlspecialchars($occupant['name'] ?? '') ?>" required style="border-radius: 6px;">
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label class="font-weight-bold text-dark" style="font-size: 12px; margin-bottom: 6px;">NIK / NIP</label>
|
||||
<input type="text" class="form-control form-control-sm" name="nik_nip" id="nik_nip" value="<?= htmlspecialchars($occupant['nik_nip'] ?? '') ?>" style="border-radius: 6px;" required>
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label class="font-weight-bold text-dark" style="font-size: 12px; margin-bottom: 6px;">Phone Number</label>
|
||||
<input type="text" class="form-control form-control-sm bg-light" name="no_hp" id="no_hp" value="<?= htmlspecialchars($occupant['no_hp'] ?? '') ?>" readonly style="border-radius: 6px; cursor: not-allowed; color: #6c757d;">
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" id="btnLanjut" class="btn btn-block btn-primary btn-lg font-weight-medium auth-form-btn" style="border-radius: 8px; font-size: 14px; padding: 12px;">Next <i class="mdi mdi-arrow-right ml-1"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2: Edit Foto -->
|
||||
<div id="step2-foto" style="display: none;">
|
||||
<div class="text-center mb-4">
|
||||
<?php
|
||||
$fotoSrc = (!empty($occupant['foto']) && file_exists($occupant['foto'])) ? $occupant['foto'] : 'images/faces/user.jpg';
|
||||
?>
|
||||
<div class="foto-wrapper" style="cursor: pointer; display: inline-block; position: relative;" onclick="startCamera()">
|
||||
<img id="previewFoto" src="<?= htmlspecialchars($fotoSrc) ?>" class="foto-circle" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 3px solid #e0e7ff; box-shadow: 0 4px 10px rgba(0,0,0,0.05); transition: transform 0.2s;">
|
||||
<div class="foto-badge" style="position: absolute; bottom: 5px; right: 5px; width: 32px; height: 32px; border-radius: 50%; background: #4747a1; display: flex; align-items: center; justify-content: center; border: 3px solid #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.2);">
|
||||
<i class="mdi mdi-camera text-white" style="font-size: 16px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-muted" style="font-size:12px; font-weight:500;"><i class="mdi mdi-cursor-default-click text-primary mr-1"></i>Click the photo above to update</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="foto_data" id="fotoData" value="" form="editForm">
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="button" id="btnBack" class="btn btn-light" style="border-radius: 8px; font-size: 14px; padding: 12px 20px;"><i class="mdi mdi-arrow-left mr-1"></i> Back</button>
|
||||
<button type="button" id="btnSimpan" class="btn btn-primary" style="border-radius: 8px; font-size: 14px; padding: 12px 30px; flex: 1; margin-left: 10px;"><i class="mdi mdi-content-save mr-1"></i> Save Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="step3-success" class="success-checkmark" style="display: none; padding-top: 30px;">
|
||||
<div class="checkmark-circle text-center">
|
||||
<div style="width: 80px; height: 80px; border-radius: 50%; background: #10b981; display: inline-flex; justify-content: center; align-items: center; margin-bottom: 20px; box-shadow: 0 10px 15px -3px rgba(16, 185, 129, 0.3);">
|
||||
<i class="mdi mdi-check text-white" style="font-size: 40px; line-height: 1;"></i>
|
||||
</div>
|
||||
|
||||
<h4 class="font-weight-bold text-success mb-2 text-center">Success!</h4>
|
||||
<p class="text-muted text-center" style="font-size: 14px;">Your profile data has been updated.</p>
|
||||
<hr class="my-4">
|
||||
<p class="text-muted mb-4 text-center" style="font-size: 13px;">This page will automatically close in <span id="countdownText" class="font-weight-bold text-primary">3</span> seconds to keep your session secure.</p>
|
||||
<button type="button" onclick="Telegram.WebApp.close(); window.close()" class="btn btn-block btn-outline-primary fw-bold" style="border-radius: 8px; padding: 12px;">Close Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FULL CAMERA OVERLAY (registrasi_foto.php style) -->
|
||||
<style>
|
||||
.edit-cam-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; z-index:9999; background:#1e2223; flex-direction:column; font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif; }
|
||||
.edit-cam-overlay * { box-sizing:border-box; }
|
||||
.ecam-header { display:flex; justify-content:space-between; align-items:center; padding:20px; z-index:30; position:absolute; top:0; left:0; right:0; }
|
||||
.ecam-area { flex:1; position:relative; overflow:hidden; background:#1e2223; }
|
||||
.ecam-area video { width:100%; height:100%; object-fit:cover; transform:scaleX(-1); }
|
||||
.ecam-area canvas.ecam-overlay-cv { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
|
||||
.ecam-face-oval { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:10; }
|
||||
.ecam-face-oval svg { width:100%; height:100%; }
|
||||
#editBgRect { fill: #ffffff; }
|
||||
|
||||
.ecam-face-oval #editOvalRing { stroke:#e2e8f0; stroke-width:6; display:block; }
|
||||
.ecam-face-oval #editOvalRingOut { stroke:none; display:block; }
|
||||
|
||||
.ecam-face-oval.detecting #editBgRect { animation: bankBgGlow 0.5s infinite; filter: brightness(1.2); }
|
||||
.ecam-face-oval.success #editBgRect { fill: #00FF00; animation: none; filter: brightness(1.2); }
|
||||
.ecam-face-oval.error #editBgRect { fill: #FF0000; animation: none; filter: brightness(1.2); }
|
||||
|
||||
@keyframes bankBgGlow {
|
||||
0% { fill: #FF0000; }
|
||||
25% { fill: #FFFF00; }
|
||||
50% { fill: #00FF00; }
|
||||
75% { fill: #00FFFF; }
|
||||
100% { fill: #FF0000; }
|
||||
}
|
||||
|
||||
.ecam-bar-after {
|
||||
position:absolute; bottom:0; left:0; right:0; z-index:40;
|
||||
padding:30px 0; background: rgba(30,34,35,0.95); backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
|
||||
display:flex; align-items:center; justify-content:center; gap:40px; border-top:1px solid rgba(226, 232, 240, 0.2);
|
||||
border-top-left-radius:24px; border-top-right-radius:24px;
|
||||
pointer-events:auto; display:none;
|
||||
}
|
||||
.ecam-act-btn {
|
||||
background:none; border:none; display:flex; flex-direction:column; align-items:center; gap:8px; cursor:pointer;
|
||||
}
|
||||
.ecam-act-btn .ring {
|
||||
width:64px; height:64px; border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||
font-size:28px; color:#fff; transition:transform 0.2s;
|
||||
}
|
||||
.ecam-act-btn:active .ring { transform:scale(0.95); }
|
||||
.ecam-act-btn.retry .ring { background:#f59e0b; box-shadow:0 8px 20px rgba(245, 158, 11, 0.3); }
|
||||
.ecam-act-btn.save .ring { background:#10b981; box-shadow:0 8px 20px rgba(16, 185, 129, 0.3); }
|
||||
.ecam-act-btn span { font-size:14px; font-weight:600; color:#fff; }
|
||||
|
||||
/* Hide elements */
|
||||
.ecam-hud, .ecam-bm, .ecam-lm, .ecam-st-pill { display: none !important; }
|
||||
</style>
|
||||
|
||||
<div id="cameraOverlay" class="edit-cam-overlay">
|
||||
<div class="ecam-header">
|
||||
<button type="button" onclick="stopEditCamera()" style="background:rgba(255,255,255,0.2); border:none; width:40px; height:40px; border-radius:50%; color:white; display:flex; align-items:center; justify-content:center; backdrop-filter:blur(4px);">
|
||||
<i class="mdi mdi-close" style="font-size:24px;"></i>
|
||||
</button>
|
||||
<div style="color:white; font-weight:700; font-size:14px; text-shadow:0 1px 3px rgba(0,0,0,0.5);">Photo Registration</div>
|
||||
<div style="width:40px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Area -->
|
||||
<div class="ecam-area" id="editCamArea">
|
||||
<video id="editVid" autoplay playsinline muted></video>
|
||||
<canvas class="ecam-overlay-cv" id="editOvCanvas"></canvas>
|
||||
|
||||
<div class="ecam-face-oval" id="editOval">
|
||||
<svg viewBox="0 0 400 800" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<mask id="editSilhouetteMask">
|
||||
<rect width="100%" height="100%" fill="white"/>
|
||||
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect id="editBgRect" width="100%" height="100%" mask="url(#editSilhouetteMask)"/>
|
||||
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="none" id="editOvalRingOut"/>
|
||||
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="none" id="editOvalRing"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Brightness Meter -->
|
||||
<div class="ecam-bm" id="editBrightMeter" style="display:none;">
|
||||
<i class="mdi mdi-white-balance-sunny" id="editBmIcon"></i>
|
||||
<div class="ecam-meter-bar"><div class="ecam-meter-fill" id="editBmFill" style="height:50%;background:#f59e0b;"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Liveness Meter -->
|
||||
<div class="ecam-lm" id="editLiveMeter" style="display:none;">
|
||||
<i class="mdi mdi-shield-check" id="editLmIcon"></i>
|
||||
<div class="ecam-meter-bar"><div class="ecam-meter-fill" id="editLmFill" style="height:0%;background:#f59e0b;"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="ecam-loading" id="editCamLoading">
|
||||
<div class="ecam-ldring"></div>
|
||||
<p id="editLoadText">Loading camera & face detection models...</p>
|
||||
<div class="ecam-load-progress"><div class="ecam-load-progress-fill" id="editLoadProgress"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Pill -->
|
||||
<div class="ecam-st-pill searching" id="editStPill" style="display:none;">
|
||||
<i class="mdi mdi-face-recognition" id="editStIcon"></i>
|
||||
<span id="editStText">Preparing...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="ecam-preview" id="editPreviewWrap">
|
||||
<canvas id="editCapCanvas"></canvas>
|
||||
<div class="ecam-preview-tag"><i class="mdi mdi-check-circle"></i> Photo Captured</div>
|
||||
</div>
|
||||
|
||||
<!-- HUD: Shutter -->
|
||||
<div class="ecam-hud">
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="ecam-shutter" id="editBtnShutter" disabled></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bar After (retake/save) -->
|
||||
<div class="ecam-bar-after" id="editBarAfter">
|
||||
<button class="ecam-act-btn retry" id="editBtnRetake">
|
||||
<div class="ring"><i class="mdi mdi-refresh"></i></div>
|
||||
<span>Retake</span>
|
||||
</button>
|
||||
<button class="ecam-act-btn save" id="editBtnUseFoto">
|
||||
<div class="ring"><i class="mdi mdi-check"></i></div>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Alert Container -->
|
||||
<div id="customAlert" class="custom-alert warning" style="display: none;">
|
||||
<i id="customAlertIcon" class="mdi mdi-alert-circle custom-alert-icon"></i>
|
||||
<div class="custom-alert-content">
|
||||
<div id="customAlertTitle" class="custom-alert-title">Notice</div>
|
||||
<div id="customAlertMsg" class="custom-alert-msg">Warning message here.</div>
|
||||
</div>
|
||||
<i class="mdi mdi-close custom-alert-close" onclick="closeCustomAlert()"></i>
|
||||
</div>
|
||||
|
||||
<!-- Session Timer Pill -->
|
||||
<div class="session-timer-pill" id="sessionTimerPill">
|
||||
<i class="mdi mdi-timer-outline"></i>
|
||||
<span id="sessionTimerText">5:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Session Expired Overlay -->
|
||||
<div class="session-expired-overlay" id="sessionExpiredOverlay">
|
||||
<div class="expired-icon"><i class="mdi mdi-timer-off-outline"></i></div>
|
||||
<h3>Session Expired</h3>
|
||||
<p>Your 5-minute access window has ended. Please use the <b>/edit_data</b> command in Telegram to request a new session.</p>
|
||||
</div>
|
||||
|
||||
<script src="vendors/js/vendor.bundle.base.js"></script>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script>
|
||||
// Session expiry timestamp from server
|
||||
var SESSION_EXPIRES_AT = <?= (int)$tokenData['expires_at'] ?>;
|
||||
|
||||
window.Telegram.WebApp.ready();
|
||||
window.Telegram.WebApp.expand();
|
||||
|
||||
function previewImage(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('previewFoto').src = e.target.result;
|
||||
}
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function showCustomAlert(title, message, type='warning') {
|
||||
const alertBox = document.getElementById('customAlert');
|
||||
const icon = document.getElementById('customAlertIcon');
|
||||
alertBox.className = 'custom-alert ' + type;
|
||||
if(type === 'error') icon.className = 'mdi mdi-close-circle custom-alert-icon';
|
||||
else if(type === 'success') icon.className = 'mdi mdi-check-circle custom-alert-icon';
|
||||
else icon.className = 'mdi mdi-alert-circle custom-alert-icon';
|
||||
document.getElementById('customAlertTitle').innerText = title;
|
||||
document.getElementById('customAlertMsg').innerText = message;
|
||||
alertBox.style.display = 'flex';
|
||||
alertBox.style.animation = 'slideIn 0.3s ease-out';
|
||||
setTimeout(() => closeCustomAlert(), 5000);
|
||||
}
|
||||
function closeCustomAlert() {
|
||||
const alertBox = document.getElementById('customAlert');
|
||||
alertBox.style.animation = 'fadeOut 0.3s ease-out forwards';
|
||||
setTimeout(() => { alertBox.style.display = 'none'; }, 300);
|
||||
}
|
||||
|
||||
// Navigation Handlers
|
||||
const infoAlertText = document.querySelector('.info-alert-text');
|
||||
const originalInfoText = infoAlertText.innerHTML;
|
||||
|
||||
document.getElementById('btnLanjut').addEventListener('click', function() {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
if(name === '') {
|
||||
showCustomAlert('Incomplete Form', 'Full Name is required.', 'warning');
|
||||
return;
|
||||
}
|
||||
document.getElementById('step1-data').style.display = 'none';
|
||||
document.getElementById('step2-foto').style.display = 'block';
|
||||
infoAlertText.innerHTML = 'Click the photo below to update it. If you do <b>not</b> want to update the photo, click <b>Save Data</b>';
|
||||
});
|
||||
|
||||
document.getElementById('btnBack').addEventListener('click', function() {
|
||||
document.getElementById('step2-foto').style.display = 'none';
|
||||
document.getElementById('step1-data').style.display = 'block';
|
||||
infoAlertText.innerHTML = originalInfoText;
|
||||
});
|
||||
</script>
|
||||
<script src="js/face-api/face-api.min.js"></script>
|
||||
<script>
|
||||
// ── Full Camera System (registrasi_foto.php style) ──
|
||||
let editStream = null, editDetectLoop = null, editFaceOk = false, editDataURL = null;
|
||||
let editBrightInterval = null, editLivenessScore = 0;
|
||||
let editPrevLandmarks = null, editLivenessFrames = 0;
|
||||
let editModelsLoaded = false;
|
||||
|
||||
const editVid = document.getElementById('editVid');
|
||||
const editOvCanvas = document.getElementById('editOvCanvas');
|
||||
const editOval = document.getElementById('editOval');
|
||||
const editCamLoading = document.getElementById('editCamLoading');
|
||||
const editStPill = document.getElementById('editStPill');
|
||||
const editStIcon = document.getElementById('editStIcon');
|
||||
const editStText = document.getElementById('editStText');
|
||||
const editBtnShutter = document.getElementById('editBtnShutter');
|
||||
const editCamArea = document.getElementById('editCamArea');
|
||||
const editPreviewWrap = document.getElementById('editPreviewWrap');
|
||||
const editCapCanvas = document.getElementById('editCapCanvas');
|
||||
const editBarAfter = document.getElementById('editBarAfter');
|
||||
const editBmFill = document.getElementById('editBmFill');
|
||||
const editBmIcon = document.getElementById('editBmIcon');
|
||||
const editLmFill = document.getElementById('editLmFill');
|
||||
const editLmIcon = document.getElementById('editLmIcon');
|
||||
const editLoadProgress = document.getElementById('editLoadProgress');
|
||||
const editLoadText = document.getElementById('editLoadText');
|
||||
|
||||
function editSetProgress(pct) { editLoadProgress.style.width = Math.min(100, pct) + '%'; }
|
||||
function editSetStatus(type, txt) {
|
||||
editStPill.className = 'ecam-st-pill';
|
||||
if (type === 'search') { editStPill.classList.add('searching'); editStIcon.className = 'mdi mdi-face-recognition'; }
|
||||
if (type === 'ok') { editStPill.classList.add('found'); editStIcon.className = 'mdi mdi-check-circle'; }
|
||||
if (type === 'err') { editStPill.classList.add('warn'); editStIcon.className = 'mdi mdi-alert-circle'; }
|
||||
editStText.textContent = txt;
|
||||
}
|
||||
|
||||
function startCamera() {
|
||||
document.getElementById('cameraOverlay').style.display = 'flex';
|
||||
editCamLoading.style.display = 'flex';
|
||||
editLoadText.textContent = 'Loading camera & face detection models...';
|
||||
editSetProgress(5);
|
||||
editCamArea.style.display = 'block';
|
||||
editPreviewWrap.style.display = 'none';
|
||||
editBarAfter.style.display = 'none';
|
||||
document.querySelector('.ecam-hud').style.display = 'flex';
|
||||
editFaceOk = false;
|
||||
editDataURL = null;
|
||||
editLivenessScore = 0;
|
||||
editLivenessFrames = 0;
|
||||
editPrevLandmarks = null;
|
||||
editLmFill.style.height = '0%';
|
||||
|
||||
initEditCamera();
|
||||
}
|
||||
|
||||
async function initEditCamera() {
|
||||
try {
|
||||
if (!editModelsLoaded) {
|
||||
editSetProgress(20);
|
||||
editLoadText.textContent = 'Loading face detection models...';
|
||||
|
||||
const loadModelsPromise = Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri('js/face-api/models'),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri('js/face-api/models')
|
||||
]);
|
||||
await Promise.race([
|
||||
loadModelsPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("Model loading timed out after 15 seconds. Make sure your internet connection is stable.")), 15000))
|
||||
]);
|
||||
|
||||
editModelsLoaded = true;
|
||||
}
|
||||
editSetProgress(50);
|
||||
editLoadText.textContent = 'Starting camera...';
|
||||
|
||||
// Force Standard Definition (SD) settings for camera to improve performance
|
||||
const videoConstraints = {
|
||||
facingMode: 'user',
|
||||
width: { ideal: 480 }
|
||||
};
|
||||
|
||||
editStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
||||
editVid.srcObject = editStream;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => reject(new Error("Camera stream initialized but video playback timed out. Device may not support video playback.")), 15000);
|
||||
editVid.onloadedmetadata = () => {
|
||||
clearTimeout(timeoutId);
|
||||
editVid.play().then(resolve).catch((e) => reject(new Error("Failed to play video stream: " + e.message)));
|
||||
};
|
||||
});
|
||||
|
||||
editSetProgress(100);
|
||||
setTimeout(() => {
|
||||
editCamLoading.style.display = 'none';
|
||||
editStPill.style.display = 'flex';
|
||||
document.getElementById('editBrightMeter').style.display = 'flex';
|
||||
document.getElementById('editLiveMeter').style.display = 'flex';
|
||||
editSetStatus('search', 'Position your face...');
|
||||
startEditDetection();
|
||||
startEditBrightness();
|
||||
}, 400);
|
||||
|
||||
} catch(err) {
|
||||
let msg = err.message;
|
||||
if (err.name === 'NotAllowedError') msg = 'Camera access denied by browser.';
|
||||
else if (err.name === 'NotFoundError') msg = 'Camera not found.';
|
||||
stopEditCamera();
|
||||
showCustomAlert('Camera Access Failed', msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
let isDetecting = false;
|
||||
let faceState = 'idle';
|
||||
let scanStartTime = 0;
|
||||
|
||||
function startEditDetection() {
|
||||
const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.15 });
|
||||
editDetectLoop = setInterval(async () => {
|
||||
if (editVid.paused || editVid.ended || !editVid.videoWidth || isDetecting) return;
|
||||
isDetecting = true;
|
||||
|
||||
editOvCanvas.width = editVid.videoWidth;
|
||||
editOvCanvas.height = editVid.videoHeight;
|
||||
|
||||
try {
|
||||
const results = await faceapi.detectAllFaces(editVid, opts).withFaceLandmarks().run();
|
||||
if (results.length === 1) {
|
||||
const det = results[0].detection;
|
||||
const box = det.box;
|
||||
const landmarks = results[0].landmarks;
|
||||
|
||||
if (faceState === 'idle') {
|
||||
faceState = 'scanning';
|
||||
scanStartTime = Date.now();
|
||||
editOval.className = 'ecam-face-oval detecting';
|
||||
editLivenessFrames = 0; editLivenessScore = 0;
|
||||
if (landmarks) editPrevLandmarks = landmarks.positions.map(p => ({x: p.x, y: p.y}));
|
||||
} else if (faceState === 'scanning') {
|
||||
if (landmarks) {
|
||||
const pts = landmarks.positions;
|
||||
if (editPrevLandmarks && pts.length === editPrevLandmarks.length) {
|
||||
let totalDiff = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
totalDiff += Math.abs(pts[i].x - editPrevLandmarks[i].x) + Math.abs(pts[i].y - editPrevLandmarks[i].y);
|
||||
}
|
||||
const avgDiff = totalDiff / pts.length;
|
||||
if (avgDiff > 0.05 && avgDiff < 15) {
|
||||
editLivenessFrames = Math.min(editLivenessFrames + 1, 20);
|
||||
} else if (avgDiff >= 15) {
|
||||
editLivenessFrames = Math.max(0, editLivenessFrames - 2);
|
||||
} else {
|
||||
editLivenessFrames = Math.min(editLivenessFrames + 0.5, 20);
|
||||
}
|
||||
}
|
||||
editPrevLandmarks = pts.map(p => ({x: p.x, y: p.y}));
|
||||
editLivenessScore = Math.min(100, (editLivenessFrames / 12) * 100);
|
||||
}
|
||||
|
||||
if (Date.now() - scanStartTime > 1500) {
|
||||
faceState = 'showing_result';
|
||||
if (editLivenessScore >= 15) {
|
||||
editOval.className = 'ecam-face-oval success';
|
||||
clearInterval(editDetectLoop);
|
||||
clearInterval(editBrightInterval);
|
||||
|
||||
// Countdown 3...2...1
|
||||
let count = 3;
|
||||
const cdText = document.createElement('div');
|
||||
cdText.style = 'position:absolute; top:40%; left:50%; transform:translate(-50%, -50%); font-size:120px; font-weight:900; color:#10b981; z-index:999; text-shadow:0 4px 20px rgba(0,0,0,0.5);';
|
||||
cdText.innerText = count;
|
||||
editCamArea.appendChild(cdText);
|
||||
|
||||
const cdInt = setInterval(() => {
|
||||
count--;
|
||||
if (count > 0) {
|
||||
cdText.innerText = count;
|
||||
} else {
|
||||
clearInterval(cdInt);
|
||||
cdText.remove();
|
||||
editFaceOk = true;
|
||||
editCapture();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
editOval.className = 'ecam-face-oval error';
|
||||
setTimeout(() => {
|
||||
if (faceState === 'showing_result') {
|
||||
faceState = 'idle';
|
||||
editOval.className = 'ecam-face-oval';
|
||||
editLivenessFrames = 0;
|
||||
editLivenessScore = 0;
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (faceState !== 'showing_result') {
|
||||
faceState = 'idle';
|
||||
editOval.className = 'ecam-face-oval';
|
||||
editLivenessFrames = 0; editLivenessScore = 0;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Detection error:", e);
|
||||
} finally {
|
||||
isDetecting = false;
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function startEditBrightness() {
|
||||
const tmpCanvas = document.createElement('canvas');
|
||||
const tmpCtx = tmpCanvas.getContext('2d');
|
||||
editBrightInterval = setInterval(() => {
|
||||
if (editVid.paused || editVid.ended || !editVid.videoWidth) return;
|
||||
tmpCanvas.width = 80; tmpCanvas.height = 60;
|
||||
tmpCtx.drawImage(editVid, 0, 0, 80, 60);
|
||||
const data = tmpCtx.getImageData(0, 0, 80, 60).data;
|
||||
let sum = 0; const count = data.length / 4;
|
||||
for (let i = 0; i < data.length; i += 4) sum += (data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114);
|
||||
const pct = Math.min(100, Math.max(5, (sum / count / 255) * 100));
|
||||
editBmFill.style.height = pct + '%';
|
||||
if (pct < 30) { editBmFill.style.background = '#ef4444'; editBmIcon.style.color = '#ef4444'; }
|
||||
else if (pct < 55) { editBmFill.style.background = '#f59e0b'; editBmIcon.style.color = '#f59e0b'; }
|
||||
else { editBmFill.style.background = '#22c55e'; editBmIcon.style.color = '#22c55e'; }
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function editCapture() {
|
||||
if (!editFaceOk) return;
|
||||
clearInterval(editDetectLoop);
|
||||
clearInterval(editBrightInterval);
|
||||
const vw = editVid.videoWidth, vh = editVid.videoHeight;
|
||||
editCapCanvas.width = vw; editCapCanvas.height = vh;
|
||||
const ctx = editCapCanvas.getContext('2d');
|
||||
ctx.save(); ctx.translate(vw, 0); ctx.scale(-1, 1);
|
||||
ctx.drawImage(editVid, 0, 0, vw, vh);
|
||||
ctx.restore();
|
||||
editDataURL = editCapCanvas.toDataURL('image/jpeg', 0.8);
|
||||
if (editStream) editStream.getTracks().forEach(t => t.stop());
|
||||
editCamArea.style.display = 'none';
|
||||
editPreviewWrap.style.display = 'block';
|
||||
document.querySelector('.ecam-hud').style.display = 'none';
|
||||
editBarAfter.style.display = 'flex';
|
||||
}
|
||||
|
||||
function editRetake() {
|
||||
editDataURL = null;
|
||||
editLivenessScore = 0; editLivenessFrames = 0; editPrevLandmarks = null;
|
||||
editLmFill.style.height = '0%';
|
||||
editPreviewWrap.style.display = 'none';
|
||||
editCamArea.style.display = 'block';
|
||||
editBarAfter.style.display = 'none';
|
||||
document.querySelector('.ecam-hud').style.display = 'flex';
|
||||
editCamLoading.style.display = 'flex';
|
||||
editLoadText.textContent = 'Reloading camera...';
|
||||
editSetProgress(50);
|
||||
|
||||
faceState = 'idle';
|
||||
editOval.className = 'ecam-face-oval';
|
||||
editFaceOk = false;
|
||||
|
||||
initEditCamera();
|
||||
}
|
||||
|
||||
function editUseFoto() {
|
||||
if (!editDataURL) return;
|
||||
document.getElementById('previewFoto').src = editDataURL;
|
||||
document.getElementById('fotoData').value = editDataURL;
|
||||
stopEditCamera();
|
||||
showCustomAlert('Success', 'Photo captured successfully. Click Save Data to save.', 'success');
|
||||
}
|
||||
|
||||
function stopEditCamera() {
|
||||
document.getElementById('cameraOverlay').style.display = 'none';
|
||||
clearInterval(editDetectLoop);
|
||||
clearInterval(editBrightInterval);
|
||||
if (editStream) { editStream.getTracks().forEach(t => t.stop()); editStream = null; }
|
||||
}
|
||||
|
||||
editBtnShutter.addEventListener('click', editCapture);
|
||||
document.getElementById('editBtnRetake').addEventListener('click', editRetake);
|
||||
document.getElementById('editBtnUseFoto').addEventListener('click', editUseFoto);
|
||||
|
||||
document.getElementById('btnSimpan').addEventListener('click', function() {
|
||||
// Validate step 1 data again just in case
|
||||
const name = document.getElementById('name').value.trim();
|
||||
if(name === '') {
|
||||
showCustomAlert('Incomplete Form', 'Full Name is required.', 'warning');
|
||||
document.getElementById('step2-foto').style.display = 'none';
|
||||
document.getElementById('step1-data').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnSimpan');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm mr-2" role="status" aria-hidden="true"></span>Saving...';
|
||||
btn.disabled = true;
|
||||
document.getElementById('btnBack').disabled = true;
|
||||
|
||||
const form = document.getElementById('editForm');
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch('', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
// Success
|
||||
document.getElementById('step2-foto').style.display = 'none';
|
||||
document.getElementById('step3-success').style.display = 'block';
|
||||
|
||||
let countdown = 3;
|
||||
const cText = document.getElementById('countdownText');
|
||||
if(cText) cText.innerText = countdown;
|
||||
setInterval(() => {
|
||||
countdown--;
|
||||
if(cText) cText.innerText = countdown;
|
||||
if(countdown <= 0) {
|
||||
if (window.Telegram && window.Telegram.WebApp) {
|
||||
window.Telegram.WebApp.close();
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} else {
|
||||
showCustomAlert('Failed', data.message, 'error');
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
document.getElementById('btnBack').disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('Network Error', 'A connection error occurred.', 'error');
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
document.getElementById('btnBack').disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SESSION EXPIRY TIMER ───
|
||||
(function() {
|
||||
var timerPill = document.getElementById('sessionTimerPill');
|
||||
var timerText = document.getElementById('sessionTimerText');
|
||||
var expiredOverlay = document.getElementById('sessionExpiredOverlay');
|
||||
var sessionCompleted = false;
|
||||
|
||||
function updateSessionTimer() {
|
||||
if (sessionCompleted) return;
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var remaining = SESSION_EXPIRES_AT - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
// Session expired - stop camera if active, hide ALL content
|
||||
timerPill.style.display = 'none';
|
||||
// Stop camera if it's open
|
||||
if (typeof stopEditCamera === 'function') stopEditCamera();
|
||||
// Hide all main content
|
||||
var mainContent = document.querySelector('.auth-form-light');
|
||||
if (mainContent) mainContent.style.display = 'none';
|
||||
var cameraOverlay = document.getElementById('cameraOverlay');
|
||||
if (cameraOverlay) cameraOverlay.style.display = 'none';
|
||||
// Show expired overlay
|
||||
expiredOverlay.style.display = 'flex';
|
||||
// Auto-close after 3 seconds
|
||||
setTimeout(function() {
|
||||
if (window.Telegram && window.Telegram.WebApp) {
|
||||
window.Telegram.WebApp.close();
|
||||
}
|
||||
window.close();
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
var mins = Math.floor(remaining / 60);
|
||||
var secs = remaining % 60;
|
||||
timerText.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
|
||||
|
||||
if (remaining <= 60) {
|
||||
timerPill.classList.add('urgent');
|
||||
} else {
|
||||
timerPill.classList.remove('urgent');
|
||||
}
|
||||
|
||||
setTimeout(updateSessionTimer, 1000);
|
||||
}
|
||||
|
||||
// Stop timer if user saves data successfully
|
||||
var origStep3 = document.getElementById('step3-success');
|
||||
if (origStep3) {
|
||||
var observer = new MutationObserver(function() {
|
||||
if (origStep3.style.display !== 'none') {
|
||||
sessionCompleted = true;
|
||||
timerPill.style.display = 'none';
|
||||
}
|
||||
});
|
||||
observer.observe(origStep3, { attributes: true, attributeFilter: ['style'] });
|
||||
}
|
||||
|
||||
updateSessionTimer();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
include "koneksi.php";
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(["status" => "error", "message" => "Method tidak diizinkan"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = isset($_POST['id_occupant']) ? $_POST['id_occupant'] : '';
|
||||
if (empty($id) || !is_numeric($id)) {
|
||||
echo json_encode(["status" => "error", "message" => "ID tidak valid"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$name = isset($_POST['name']) ? trim($_POST['name']) : '';
|
||||
$nik_nip = isset($_POST['nik_nip']) ? trim($_POST['nik_nip']) : '';
|
||||
$no_hp = isset($_POST['no_hp']) ? trim($_POST['no_hp']) : '';
|
||||
|
||||
if (empty($name) || empty($no_hp)) {
|
||||
echo json_encode(["status" => "error", "message" => "Nama dan Username wajib diisi"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Cek no_hp unik (selain diri sendiri)
|
||||
$cek = $pdo->prepare("SELECT COUNT(*) FROM occupant WHERE no_hp = ? AND id_occupant != ?");
|
||||
$cek->execute([$no_hp, $id]);
|
||||
if ($cek->fetchColumn() > 0) {
|
||||
echo json_encode(["status" => "error", "message" => "Nomor Handphone sudah digunakan"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("UPDATE occupant SET name = ?, nik_nip = ?, no_hp = ? WHERE id_occupant = ?");
|
||||
$stmt->execute([$name, $nik_nip, $no_hp, $id]);
|
||||
|
||||
echo json_encode(["status" => "success"]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(["status" => "error", "message" => "Gagal mengupdate: " . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"command":"SCAN","ssid":"","password":"","scan_results":[],"status":"offline","current_ssid":"Unknown","last_seen":0,"esp_ip":"Unknown","change_status":"idle","rssi":-90,"wifi_notified_offline":true}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
/*
|
||||
* ============================================
|
||||
* ESP32 Universal TFT UI Renderer
|
||||
* ============================================
|
||||
* Library: TFT_eSPI, ArduinoJson, WiFi, HTTPClient
|
||||
* Screen: ILI9488 480x320 (Landscape)
|
||||
*
|
||||
* Upload this code ONCE to ESP32.
|
||||
* All UI changes are synced wirelessly from the web editor.
|
||||
* ============================================
|
||||
*/
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <TFT_eSPI.h>
|
||||
|
||||
// ===== CONFIGURATION =====
|
||||
const char* WIFI_SSID = "YOUR_WIFI_SSID";
|
||||
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
|
||||
const char* SERVER_URL = "http://YOUR_SERVER_IP/fiberid/api_sync.php?action=latest";
|
||||
const char* PUSH_URL = "http://YOUR_SERVER_IP/fiberid/api_sync.php?action=push";
|
||||
const int POLL_INTERVAL = 5000; // ms between polls
|
||||
|
||||
// ===== GLOBALS =====
|
||||
TFT_eSPI tft = TFT_eSPI();
|
||||
TFT_eSprite sprite = TFT_eSprite(&tft); // Double buffer
|
||||
|
||||
String lastVersionHash = "";
|
||||
unsigned long lastPollTime = 0;
|
||||
|
||||
// ===== RGB565 Conversion =====
|
||||
uint16_t hexToRGB565(const char* hex) {
|
||||
// Skip '#' if present
|
||||
if (hex[0] == '#') hex++;
|
||||
uint32_t val = strtoul(hex, NULL, 16);
|
||||
uint8_t r = (val >> 16) & 0xFF;
|
||||
uint8_t g = (val >> 8) & 0xFF;
|
||||
uint8_t b = val & 0xFF;
|
||||
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
|
||||
}
|
||||
|
||||
// ===== Render UI from JSON =====
|
||||
void renderUI(String jsonPayload) {
|
||||
DynamicJsonDocument doc(32768); // 32KB buffer
|
||||
DeserializationError err = deserializeJson(doc, jsonPayload);
|
||||
|
||||
if (err) {
|
||||
Serial.print("JSON parse error: ");
|
||||
Serial.println(err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Create sprite for double-buffering (anti-flicker)
|
||||
sprite.createSprite(480, 320);
|
||||
sprite.fillSprite(TFT_BLACK);
|
||||
|
||||
JsonArray elements = doc["elements"].as<JsonArray>();
|
||||
|
||||
for (JsonObject item : elements) {
|
||||
const char* type = item["type"] | "unknown";
|
||||
int x = item["x"] | 0;
|
||||
int y = item["y"] | 0;
|
||||
int w = item["w"] | 0;
|
||||
int h = item["h"] | 0;
|
||||
const char* fillHex = item["fill"] | "#FFFFFF";
|
||||
uint16_t color = hexToRGB565(fillHex);
|
||||
int radius = item["radius"] | 0;
|
||||
|
||||
// ---- RECTANGLE ----
|
||||
if (strcmp(type, "rect") == 0 || strcmp(type, "roundedRect") == 0) {
|
||||
if (radius > 0) {
|
||||
sprite.fillRoundRect(x, y, w, h, radius, color);
|
||||
} else {
|
||||
sprite.fillRect(x, y, w, h, color);
|
||||
}
|
||||
// Stroke
|
||||
const char* strokeHex = item["stroke"] | "transparent";
|
||||
if (strcmp(strokeHex, "transparent") != 0) {
|
||||
uint16_t strokeColor = hexToRGB565(strokeHex);
|
||||
if (radius > 0) {
|
||||
sprite.drawRoundRect(x, y, w, h, radius, strokeColor);
|
||||
} else {
|
||||
sprite.drawRect(x, y, w, h, strokeColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---- CIRCLE ----
|
||||
else if (strcmp(type, "circle") == 0) {
|
||||
int rv = item["radius_val"] | (w / 2);
|
||||
int cx = x + rv;
|
||||
int cy = y + rv;
|
||||
sprite.fillCircle(cx, cy, rv, color);
|
||||
}
|
||||
// ---- LINE ----
|
||||
else if (strcmp(type, "line") == 0) {
|
||||
int x2 = item["x2"] | (x + w);
|
||||
int y2 = item["y2"] | y;
|
||||
sprite.drawLine(x, y, x2, y2, color);
|
||||
}
|
||||
// ---- TEXT / HEADING ----
|
||||
else if (strcmp(type, "text") == 0 || strcmp(type, "heading") == 0) {
|
||||
const char* text = item["text"] | "Text";
|
||||
int fontSize = item["fontSize"] | 16;
|
||||
int tftSize = max(1, fontSize / 8);
|
||||
sprite.setTextSize(tftSize);
|
||||
sprite.setTextColor(color);
|
||||
sprite.drawString(text, x, y);
|
||||
}
|
||||
// ---- BUTTON ----
|
||||
else if (strcmp(type, "button") == 0) {
|
||||
int br = item["radius"] | 8;
|
||||
sprite.fillRoundRect(x, y, w, h, br, color);
|
||||
const char* text = item["text"] | "";
|
||||
if (strlen(text) > 0) {
|
||||
const char* txtColorHex = item["textColor"] | "#FFFFFF";
|
||||
uint16_t txtColor = hexToRGB565(txtColorHex);
|
||||
int fontSize = item["fontSize"] | 16;
|
||||
int tftSize = max(1, fontSize / 8);
|
||||
sprite.setTextSize(tftSize);
|
||||
sprite.setTextColor(txtColor);
|
||||
sprite.setTextDatum(MC_DATUM);
|
||||
sprite.drawString(text, x + w/2, y + h/2);
|
||||
sprite.setTextDatum(TL_DATUM);
|
||||
}
|
||||
}
|
||||
// ---- TOGGLE ----
|
||||
else if (strcmp(type, "toggle") == 0) {
|
||||
sprite.fillRoundRect(x, y, w, h, h/2, color);
|
||||
sprite.fillCircle(x + w - h/2, y + h/2, h/2 - 2, TFT_WHITE);
|
||||
}
|
||||
// ---- SLIDER ----
|
||||
else if (strcmp(type, "slider") == 0) {
|
||||
int trackY = y + h/2 - 2;
|
||||
sprite.fillRoundRect(x, trackY, w, 4, 2, 0x8410); // gray track
|
||||
int handleX = x + (int)(w * 0.4);
|
||||
sprite.fillCircle(handleX, y + h/2, 8, color);
|
||||
}
|
||||
// ---- PROGRESS BAR ----
|
||||
else if (strcmp(type, "progressbar") == 0) {
|
||||
sprite.fillRoundRect(x, y, w, h, 4, 0x2104); // dark bg
|
||||
int pct = item["value"] | 60;
|
||||
int pw = w * pct / 100;
|
||||
sprite.fillRoundRect(x, y, pw, h, 4, color);
|
||||
}
|
||||
// ---- IMAGE PLACEHOLDER ----
|
||||
else if (strcmp(type, "image") == 0) {
|
||||
sprite.drawRect(x, y, w, h, 0x8410);
|
||||
sprite.setTextSize(1);
|
||||
sprite.setTextColor(0x8410);
|
||||
sprite.setTextDatum(MC_DATUM);
|
||||
sprite.drawString("IMG", x + w/2, y + h/2);
|
||||
sprite.setTextDatum(TL_DATUM);
|
||||
}
|
||||
}
|
||||
|
||||
// Push sprite to display (one shot = no flicker)
|
||||
sprite.pushSprite(0, 0);
|
||||
sprite.deleteSprite();
|
||||
|
||||
Serial.println("UI rendered successfully.");
|
||||
}
|
||||
|
||||
// ===== Poll Server =====
|
||||
void pollServer() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
Serial.println("WiFi not connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(SERVER_URL);
|
||||
int httpCode = http.GET();
|
||||
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String payload = http.getString();
|
||||
|
||||
// Parse just to check version hash
|
||||
DynamicJsonDocument doc(1024);
|
||||
deserializeJson(doc, payload);
|
||||
|
||||
String newHash = doc["version_hash"].as<String>();
|
||||
|
||||
if (newHash != lastVersionHash) {
|
||||
Serial.println("New design detected! Rendering...");
|
||||
lastVersionHash = newHash;
|
||||
renderUI(payload);
|
||||
} else {
|
||||
Serial.println("No changes detected.");
|
||||
}
|
||||
} else {
|
||||
Serial.printf("HTTP error: %d\n", httpCode);
|
||||
}
|
||||
|
||||
http.end();
|
||||
}
|
||||
|
||||
// ===== SETUP =====
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// Init TFT
|
||||
tft.init();
|
||||
tft.setRotation(1); // Landscape 480x320
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_WHITE);
|
||||
tft.setTextSize(2);
|
||||
tft.drawString("Connecting...", 80, 230);
|
||||
|
||||
// Connect WiFi
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||
Serial.print("Connecting to WiFi");
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.println("\nWiFi connected!");
|
||||
Serial.print("IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.drawString("WiFi OK!", 100, 220);
|
||||
tft.drawString(WiFi.localIP().toString().c_str(), 60, 250);
|
||||
delay(1000);
|
||||
} else {
|
||||
Serial.println("\nWiFi FAILED!");
|
||||
tft.fillScreen(TFT_RED);
|
||||
tft.drawString("WiFi Failed!", 70, 230);
|
||||
}
|
||||
|
||||
// First poll
|
||||
pollServer();
|
||||
}
|
||||
|
||||
// ===== LOOP =====
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
if (now - lastPollTime >= POLL_INTERVAL) {
|
||||
lastPollTime = now;
|
||||
pollServer();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================
|
||||
* pushState() — Send current UI to server
|
||||
* =============================================
|
||||
* Call this after you design the initial UI in setup().
|
||||
* It serializes all drawn elements and POSTs them to api_sync.php?action=push
|
||||
* so the web editor (design.php) can load and visually redesign them.
|
||||
*
|
||||
* HOW TO USE:
|
||||
* 1. Design your UI in setup() using tft.fillRect(), tft.drawString(), etc.
|
||||
* 2. At the end of setup(), call pushState() with your elements.
|
||||
* 3. Open design.php in browser and click "Pull ESP32" to see your design.
|
||||
*
|
||||
* Example:
|
||||
* pushState("[{\"type\":\"rect\",\"x\":10,\"y\":10,\"w\":100,\"h\":50,\"fill\":\"#FF0000\"}]");
|
||||
*/
|
||||
void pushState(String elementsJsonArray) {
|
||||
if (WiFi.status() != WL_CONNECTED) return;
|
||||
|
||||
// Build full design JSON
|
||||
String json = "{\"projectName\":\"ESP32 Design\",\"screens\":[{\"name\":\"Screen 1\",\"bgColor\":\"#000000\",\"objects\":";
|
||||
json += elementsJsonArray;
|
||||
json += "}]}";
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(PUSH_URL);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
int httpCode = http.POST(json);
|
||||
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
Serial.println("UI state pushed to server!");
|
||||
Serial.println(http.getString());
|
||||
} else {
|
||||
Serial.printf("Push failed, HTTP code: %d\n", httpCode);
|
||||
}
|
||||
http.end();
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// ==========================================
|
||||
// CONFIGURATION
|
||||
// ==========================================
|
||||
// IMPORTANT: Ganti HOST_IP dengan IP Laptop Anda
|
||||
// (Pastikan laptop dan esp32 satu jaringan jika tidak pakai public URL)
|
||||
const char* host_api = "http://192.168.1.X/fiberid/api_wifi.php";
|
||||
|
||||
unsigned long lastPollTime = 0;
|
||||
const unsigned long pollInterval = 3000; // Poll setiap 3 detik
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// ==========================================
|
||||
// WIFI MANAGER INITIALIZATION
|
||||
// ==========================================
|
||||
// Ini fungsi yang memungkinkan Anda mengganti wifi sekitar
|
||||
// Tanpa colok kabel usb.
|
||||
WiFiManager wm;
|
||||
|
||||
// wm.resetSettings(); // Unleash this to factory reset wifi settings
|
||||
|
||||
Serial.println("Connecting to WiFi or starting Access Point...");
|
||||
|
||||
// Jika gagal connect ke wifi yang pernah tersimpan,
|
||||
// ESP32 akan menjadi Access Point bernama "ESP32_SmartDoor".
|
||||
// Sambung via HP, buka 192.168.4.1 untuk mengganti WiFi.
|
||||
bool res = wm.autoConnect("ESP32_SmartDoor");
|
||||
|
||||
if(!res) {
|
||||
Serial.println("Failed to connect");
|
||||
// ESP.restart();
|
||||
} else {
|
||||
Serial.println("Connected to WiFi!");
|
||||
Serial.print("IP Address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
if (millis() - lastPollTime >= pollInterval) {
|
||||
lastPollTime = millis();
|
||||
pollServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void pollServer() {
|
||||
HTTPClient http;
|
||||
String current_ssid = WiFi.SSID();
|
||||
|
||||
// Encode current SSID for URL
|
||||
current_ssid.replace(" ", "%20");
|
||||
|
||||
String url = String(host_api) + "?action=esp_poll&ssid=" + current_ssid;
|
||||
|
||||
http.begin(url);
|
||||
int httpCode = http.GET();
|
||||
|
||||
if (httpCode > 0) {
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String payload = http.getString();
|
||||
processCommand(payload);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
|
||||
}
|
||||
|
||||
http.end();
|
||||
}
|
||||
|
||||
void processCommand(String jsonPayload) {
|
||||
// Parsing JSON Response dari dashboard
|
||||
StaticJsonDocument<512> doc;
|
||||
DeserializationError error = deserializeJson(doc, jsonPayload);
|
||||
|
||||
if (error) {
|
||||
Serial.print("deserializeJson() failed: ");
|
||||
Serial.println(error.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const char* command = doc["command"]; // "IDLE", "SCAN", atau "CHANGE"
|
||||
|
||||
if (strcmp(command, "SCAN") == 0) {
|
||||
Serial.println("Command: SCAN received. Scanning networks...");
|
||||
scanAndSendResults();
|
||||
}
|
||||
else if (strcmp(command, "CHANGE") == 0) {
|
||||
const char* new_ssid = doc["ssid"];
|
||||
const char* new_pass = doc["password"];
|
||||
|
||||
Serial.println("Command: CHANGE received.");
|
||||
Serial.print("New SSID: "); Serial.println(new_ssid);
|
||||
|
||||
// Ganti preferensi WiFi
|
||||
WiFi.begin(new_ssid, new_pass);
|
||||
|
||||
// Simpan di WiFiManager preference
|
||||
WiFiManager wm;
|
||||
wm.setConfigPortalTimeout(10);
|
||||
|
||||
Serial.println("Restarting ESP32 to connect to new Network...");
|
||||
delay(2000);
|
||||
ESP.restart(); // Reboot ESP32
|
||||
}
|
||||
}
|
||||
|
||||
void scanAndSendResults() {
|
||||
int n = WiFi.scanNetworks();
|
||||
Serial.println("Scan done");
|
||||
|
||||
DynamiJsonDocument doc(2048);
|
||||
JsonArray networks = doc.createNestedArray("networks");
|
||||
|
||||
if (n == 0) {
|
||||
Serial.println("No networks found");
|
||||
} else {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
// Ambil data tiap network
|
||||
JsonObject network = networks.createNestedObject();
|
||||
network["ssid"] = WiFi.SSID(i);
|
||||
network["signal"] = WiFi.RSSI(i); // Convert to % nanti di PHP
|
||||
network["locked"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
// Format ke string JSON
|
||||
String jsonOutput;
|
||||
serializeJson(doc, jsonOutput);
|
||||
|
||||
// Kirim hasil scan ke PHP Dashboard
|
||||
HTTPClient http;
|
||||
String url = String(host_api) + "?action=esp_post_scan";
|
||||
http.begin(url);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
|
||||
int httpCode = http.POST(jsonOutput);
|
||||
|
||||
if (httpCode > 0) {
|
||||
Serial.printf("[HTTP] POST... code: %d\n", httpCode);
|
||||
} else {
|
||||
Serial.printf("[HTTP] POST... failed, error: %s\n", http.errorToString(httpCode).c_str());
|
||||
}
|
||||
http.end();
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[env:esp32dev]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson @ ^6.21.3
|
||||
tzapu/WiFiManager @ ^2.0.16-rc.2
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
// ==========================================
|
||||
// CONFIGURATION
|
||||
// ==========================================
|
||||
// IMPORTANT: Ganti HOST_IP dengan IP Laptop Anda
|
||||
// (Pastikan laptop dan esp32 satu jaringan jika tidak pakai public URL)
|
||||
const char* host_api = "http://192.168.1.X/fiberid/api_wifi.php";
|
||||
|
||||
unsigned long lastPollTime = 0;
|
||||
const unsigned long pollInterval = 3000; // Poll setiap 3 detik
|
||||
|
||||
// Preferences untuk menyimpan WiFi lama
|
||||
Preferences prefs;
|
||||
|
||||
// Forward declarations
|
||||
void pollServer();
|
||||
void processCommand(String jsonPayload);
|
||||
void scanAndSendResults();
|
||||
void reportChangeStatus(const char* status, const char* msg);
|
||||
bool tryConnectWiFi(const char* ssid, const char* pass, int timeoutSec);
|
||||
|
||||
// ==========================================
|
||||
// CUSTOM PORTAL CSS — Identia.id Branding
|
||||
// ==========================================
|
||||
const char PORTAL_CUSTOM_CSS[] PROGMEM = R"rawliteral(
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
/* === Global Reset & Base === */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
|
||||
background: #f0f2f8 !important;
|
||||
color: #1e293b !important;
|
||||
margin: 0; padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === Top Header Banner === */
|
||||
body::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, #4b4ba9 0%, #3a3a8a 50%, #2d2d6e 100%);
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
z-index: 0;
|
||||
border-radius: 0 0 30px 30px;
|
||||
}
|
||||
|
||||
/* === Page Title / Brand === */
|
||||
div[style*='text-align'], .wrap, body > div:first-of-type {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
h1, h2, .wrap > h1 {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-weight: 800 !important;
|
||||
font-size: 22px !important;
|
||||
color: #ffffff !important;
|
||||
text-align: center !important;
|
||||
margin: 0 !important;
|
||||
padding: 28px 0 6px 0 !important;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 13px !important;
|
||||
color: rgba(255,255,255,0.8) !important;
|
||||
text-align: center !important;
|
||||
margin: 0 0 18px 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* === Main Container / Card === */
|
||||
.wrap, form, #diag {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #ffffff !important;
|
||||
border-radius: 16px !important;
|
||||
padding: 22px 20px !important;
|
||||
margin: 8px auto !important;
|
||||
max-width: 380px !important;
|
||||
box-shadow: 0 4px 24px rgba(75,75,169,0.10), 0 1px 3px rgba(0,0,0,0.06) !important;
|
||||
border: 1px solid #e8e8f0 !important;
|
||||
}
|
||||
|
||||
/* === Form Labels === */
|
||||
label {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #475569 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
margin-bottom: 4px !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* === Text Inputs === */
|
||||
input[type='text'], input[type='password'], input:not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']) {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
width: 100% !important;
|
||||
padding: 10px 14px !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 13px !important;
|
||||
background: #f8fafc !important;
|
||||
color: #1e293b !important;
|
||||
outline: none !important;
|
||||
transition: border-color 0.2s, box-shadow 0.2s !important;
|
||||
box-sizing: border-box !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
input[type='text']:focus, input[type='password']:focus, input:not([type='submit']):not([type='button']):focus {
|
||||
border-color: #4b4ba9 !important;
|
||||
box-shadow: 0 0 0 3px rgba(75,75,169,0.12) !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
/* === Primary Buttons === */
|
||||
button, input[type='submit'], input[type='button'], .btn {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
padding: 11px 20px !important;
|
||||
background: linear-gradient(135deg, #4b4ba9 0%, #5e5ec2 100%) !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.25s ease !important;
|
||||
text-align: center !important;
|
||||
letter-spacing: 0.3px !important;
|
||||
margin: 6px 0 !important;
|
||||
box-shadow: 0 2px 8px rgba(75,75,169,0.25) !important;
|
||||
}
|
||||
|
||||
button:hover, input[type='submit']:hover, .btn:hover {
|
||||
background: linear-gradient(135deg, #3d3d8f 0%, #4b4ba9 100%) !important;
|
||||
box-shadow: 0 4px 16px rgba(75,75,169,0.35) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
button:active, input[type='submit']:active, .btn:active {
|
||||
transform: translateY(0) !important;
|
||||
box-shadow: 0 1px 4px rgba(75,75,169,0.2) !important;
|
||||
}
|
||||
|
||||
/* === Links (Menu Items) === */
|
||||
a {
|
||||
display: block !important;
|
||||
text-decoration: none !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
color: #4b4ba9 !important;
|
||||
padding: 10px 14px !important;
|
||||
margin: 4px 0 !important;
|
||||
border-radius: 10px !important;
|
||||
border: 1.5px solid #e8e8f0 !important;
|
||||
background: #fafaff !important;
|
||||
transition: all 0.2s ease !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: #f0f0ff !important;
|
||||
border-color: #4b4ba9 !important;
|
||||
color: #3a3a8a !important;
|
||||
}
|
||||
|
||||
/* === WiFi Network List Items === */
|
||||
#s, .c, div.c {
|
||||
border-radius: 10px !important;
|
||||
border: 1.5px solid #e8e8f0 !important;
|
||||
padding: 10px 14px !important;
|
||||
margin: 4px 0 !important;
|
||||
background: #fafaff !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
|
||||
#s:hover, .c:hover, div.c:hover {
|
||||
background: #f0f0ff !important;
|
||||
border-color: #4b4ba9 !important;
|
||||
}
|
||||
|
||||
/* === Signal Strength Bars === */
|
||||
.q {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
/* === Info Alert Banner === */
|
||||
.msg, #diag, .r {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.5 !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
/* === Custom Alert Banners === */
|
||||
body .wrap::before {
|
||||
content: '🔒 Portal Konfigurasi WiFi — Identia.id Smart Door System';
|
||||
display: block;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #e8f0fe 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* === Status/Section Dividers === */
|
||||
hr {
|
||||
border: none !important;
|
||||
height: 1px !important;
|
||||
background: #e8e8f0 !important;
|
||||
margin: 14px 0 !important;
|
||||
}
|
||||
|
||||
/* === Footer === */
|
||||
.c, .q, small, .r, div[style*='font-size:11'] {
|
||||
font-size: 11px !important;
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* === Warning Alert === */
|
||||
body .wrap::after {
|
||||
content: '⚠️ Pastikan Anda mengetahui password WiFi yang dituju sebelum mengubah konfigurasi.';
|
||||
display: block;
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 10px;
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
margin-top: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #c7c7e0; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #4b4ba9; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 420px) {
|
||||
.wrap, form, #diag {
|
||||
margin: 6px 10px !important;
|
||||
padding: 18px 16px !important;
|
||||
}
|
||||
h1, h2, .wrap > h1 {
|
||||
font-size: 18px !important;
|
||||
padding-top: 22px !important;
|
||||
}
|
||||
body::before {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
)rawliteral";
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// ==========================================
|
||||
// SIMPAN WIFI LAMA DI PREFERENCES
|
||||
// ==========================================
|
||||
prefs.begin("wifi_backup", false);
|
||||
|
||||
// ==========================================
|
||||
// WIFI MANAGER INITIALIZATION
|
||||
// ==========================================
|
||||
WiFiManager wm;
|
||||
|
||||
// wm.resetSettings(); // Unleash this to factory reset wifi settings
|
||||
|
||||
// === APPLY CUSTOM PORTAL DESIGN ===
|
||||
wm.setTitle("Identia.id");
|
||||
wm.setCustomHeadElement(PORTAL_CUSTOM_CSS);
|
||||
wm.setConfigPortalTimeout(180); // Portal timeout 3 menit
|
||||
|
||||
Serial.println("Connecting to WiFi or starting Access Point...");
|
||||
|
||||
// Jika gagal connect ke wifi yang pernah tersimpan,
|
||||
// ESP32 akan menjadi Access Point bernama "Identia.id".
|
||||
// Sambung via HP, buka 192.168.4.1 untuk mengganti WiFi.
|
||||
bool res = wm.autoConnect("Identia.id");
|
||||
|
||||
if(!res) {
|
||||
Serial.println("Failed to connect");
|
||||
// ESP.restart();
|
||||
} else {
|
||||
Serial.println("Connected to WiFi!");
|
||||
Serial.print("IP Address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
|
||||
// Simpan WiFi yang berhasil connect sebagai backup
|
||||
prefs.putString("ssid", WiFi.SSID());
|
||||
prefs.putString("pass", WiFi.psk());
|
||||
Serial.println("WiFi credentials backed up.");
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
if (millis() - lastPollTime >= pollInterval) {
|
||||
lastPollTime = millis();
|
||||
pollServer();
|
||||
}
|
||||
} else {
|
||||
// Jika WiFi terputus tiba-tiba, coba reconnect
|
||||
static unsigned long lastReconnectAttempt = 0;
|
||||
if (millis() - lastReconnectAttempt >= 10000) { // Setiap 10 detik
|
||||
lastReconnectAttempt = millis();
|
||||
Serial.println("WiFi disconnected. Attempting reconnect...");
|
||||
|
||||
String backupSSID = prefs.getString("ssid", "");
|
||||
String backupPass = prefs.getString("pass", "");
|
||||
|
||||
if (backupSSID.length() > 0) {
|
||||
WiFi.begin(backupSSID.c_str(), backupPass.c_str());
|
||||
} else {
|
||||
WiFi.reconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void pollServer() {
|
||||
HTTPClient http;
|
||||
String current_ssid = WiFi.SSID();
|
||||
String current_ip = WiFi.localIP().toString();
|
||||
int current_rssi = WiFi.RSSI();
|
||||
|
||||
// Encode current SSID for URL
|
||||
current_ssid.replace(" ", "%20");
|
||||
|
||||
String url = String(host_api) + "?action=esp_poll&ssid=" + current_ssid
|
||||
+ "&ip=" + current_ip
|
||||
+ "&rssi=" + String(current_rssi);
|
||||
|
||||
http.begin(url);
|
||||
int httpCode = http.GET();
|
||||
|
||||
if (httpCode > 0) {
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String payload = http.getString();
|
||||
processCommand(payload);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
|
||||
}
|
||||
|
||||
http.end();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// REPORT CHANGE STATUS KE SERVER
|
||||
// ==========================================
|
||||
void reportChangeStatus(const char* status, const char* msg) {
|
||||
HTTPClient http;
|
||||
String url = String(host_api) + "?action=report_change&status=" + String(status) + "&msg=" + String(msg);
|
||||
http.begin(url);
|
||||
int httpCode = http.GET();
|
||||
if (httpCode > 0) {
|
||||
Serial.printf("[REPORT] Status: %s, Response: %d\n", status, httpCode);
|
||||
} else {
|
||||
Serial.printf("[REPORT] Failed: %s\n", http.errorToString(httpCode).c_str());
|
||||
}
|
||||
http.end();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TRY CONNECT WIFI DENGAN TIMEOUT
|
||||
// ==========================================
|
||||
bool tryConnectWiFi(const char* ssid, const char* pass, int timeoutSec) {
|
||||
Serial.printf("Trying to connect to '%s'...\n", ssid);
|
||||
|
||||
WiFi.disconnect(true);
|
||||
delay(500);
|
||||
WiFi.begin(ssid, pass);
|
||||
|
||||
unsigned long startAttempt = millis();
|
||||
unsigned long timeoutMs = (unsigned long)timeoutSec * 1000;
|
||||
|
||||
while (WiFi.status() != WL_CONNECTED && (millis() - startAttempt) < timeoutMs) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
Serial.println();
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("Connected to '%s'! IP: %s\n", ssid, WiFi.localIP().toString().c_str());
|
||||
return true;
|
||||
} else {
|
||||
Serial.printf("Failed to connect to '%s'\n", ssid);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void processCommand(String jsonPayload) {
|
||||
// Parsing JSON Response dari dashboard
|
||||
StaticJsonDocument<512> doc;
|
||||
DeserializationError error = deserializeJson(doc, jsonPayload);
|
||||
|
||||
if (error) {
|
||||
Serial.print("deserializeJson() failed: ");
|
||||
Serial.println(error.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const char* command = doc["command"]; // "IDLE", "SCAN", atau "CHANGE"
|
||||
|
||||
if (strcmp(command, "SCAN") == 0) {
|
||||
Serial.println("Command: SCAN received. Scanning networks...");
|
||||
scanAndSendResults();
|
||||
}
|
||||
else if (strcmp(command, "CHANGE") == 0) {
|
||||
const char* new_ssid = doc["ssid"];
|
||||
const char* new_pass = doc["password"];
|
||||
|
||||
Serial.println("Command: CHANGE received.");
|
||||
Serial.print("New SSID: "); Serial.println(new_ssid);
|
||||
|
||||
// ==========================================
|
||||
// 1. SIMPAN WIFI LAMA YANG SEDANG TERHUBUNG
|
||||
// ==========================================
|
||||
String old_ssid = WiFi.SSID();
|
||||
String old_pass = WiFi.psk();
|
||||
|
||||
Serial.printf("Backup WiFi: '%s'\n", old_ssid.c_str());
|
||||
|
||||
// Report: sedang memproses
|
||||
reportChangeStatus("processing", "Mencoba menghubungkan ke WiFi baru...");
|
||||
|
||||
// ==========================================
|
||||
// 2. COBA CONNECT KE WIFI BARU (TIMEOUT 15 DETIK)
|
||||
// ==========================================
|
||||
bool connected = tryConnectWiFi(new_ssid, new_pass, 15);
|
||||
|
||||
if (connected) {
|
||||
// ==========================================
|
||||
// 3a. BERHASIL → Report success, simpan backup baru, restart
|
||||
// ==========================================
|
||||
Serial.println("SUCCESS: Connected to new WiFi!");
|
||||
|
||||
// Simpan WiFi baru sebagai backup
|
||||
prefs.putString("ssid", String(new_ssid));
|
||||
prefs.putString("pass", String(new_pass));
|
||||
|
||||
// Report success ke server
|
||||
reportChangeStatus("success", "Berhasil terhubung ke WiFi baru");
|
||||
|
||||
Serial.println("Restarting ESP32...");
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
|
||||
} else {
|
||||
// ==========================================
|
||||
// 3b. GAGAL → Report failed, RECONNECT KE WIFI LAMA
|
||||
// ==========================================
|
||||
Serial.println("FAILED: Could not connect to new WiFi.");
|
||||
Serial.printf("Reconnecting to old WiFi: '%s'...\n", old_ssid.c_str());
|
||||
|
||||
// WAJIB: Reconnect ke WiFi lama
|
||||
bool reconnected = tryConnectWiFi(old_ssid.c_str(), old_pass.c_str(), 20);
|
||||
|
||||
if (reconnected) {
|
||||
Serial.println("Successfully reconnected to old WiFi!");
|
||||
// Report failure ke server (setelah kembali online)
|
||||
reportChangeStatus("failed", "Gagal terhubung ke WiFi baru. Kembali ke WiFi sebelumnya.");
|
||||
} else {
|
||||
Serial.println("CRITICAL: Could not reconnect to old WiFi either!");
|
||||
// Coba sekali lagi dengan WiFi dari Preferences
|
||||
String backupSSID = prefs.getString("ssid", "");
|
||||
String backupPass = prefs.getString("pass", "");
|
||||
if (backupSSID.length() > 0) {
|
||||
Serial.printf("Trying backup from Preferences: '%s'\n", backupSSID.c_str());
|
||||
tryConnectWiFi(backupSSID.c_str(), backupPass.c_str(), 20);
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
reportChangeStatus("failed", "Gagal terhubung. Kembali ke WiFi backup.");
|
||||
} else {
|
||||
// Jika semua gagal, restart agar WiFiManager AP muncul
|
||||
Serial.println("All reconnection attempts failed. Restarting...");
|
||||
delay(2000);
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void scanAndSendResults() {
|
||||
int n = WiFi.scanNetworks();
|
||||
Serial.println("Scan done");
|
||||
|
||||
DynamicJsonDocument doc(2048);
|
||||
JsonArray networks = doc.createNestedArray("networks");
|
||||
|
||||
if (n == 0) {
|
||||
Serial.println("No networks found");
|
||||
} else {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
// Ambil data tiap network
|
||||
JsonObject network = networks.createNestedObject();
|
||||
network["ssid"] = WiFi.SSID(i);
|
||||
network["signal"] = WiFi.RSSI(i); // Convert to % nanti di PHP
|
||||
network["locked"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
// Format ke string JSON
|
||||
String jsonOutput;
|
||||
serializeJson(doc, jsonOutput);
|
||||
|
||||
// Kirim hasil scan ke PHP Dashboard
|
||||
HTTPClient http;
|
||||
String url = String(host_api) + "?action=esp_post_scan";
|
||||
http.begin(url);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
|
||||
int httpCode = http.POST(jsonOutput);
|
||||
|
||||
if (httpCode > 0) {
|
||||
Serial.printf("[HTTP] POST... code: %d\n", httpCode);
|
||||
} else {
|
||||
Serial.printf("[HTTP] POST... failed, error: %s\n", http.errorToString(httpCode).c_str());
|
||||
}
|
||||
http.end();
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
include_once 'koneksi.php';
|
||||
date_default_timezone_set('Asia/Jakarta');
|
||||
|
||||
$start_month = isset($_GET['start_month']) ? (int)$_GET['start_month'] : (int)date('m');
|
||||
$end_month = isset($_GET['end_month']) && $_GET['end_month'] !== '' ? (int)$_GET['end_month'] : $start_month;
|
||||
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y');
|
||||
$format = isset($_GET['format']) ? $_GET['format'] : 'excel';
|
||||
|
||||
$monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
// Ensure validity of month index
|
||||
$sm_idx = $start_month - 1; if($sm_idx < 0) $sm_idx = 0; if($sm_idx > 11) $sm_idx = 11;
|
||||
$em_idx = $end_month - 1; if($em_idx < 0) $em_idx = 0; if($em_idx > 11) $em_idx = 11;
|
||||
|
||||
$start_name = $monthNames[$sm_idx];
|
||||
$end_name = $monthNames[$em_idx];
|
||||
|
||||
if ($start_month === $end_month) {
|
||||
$period_title = "Month $start_name $year";
|
||||
} else {
|
||||
$period_title = "Months $start_name - $end_name $year";
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM schedule WHERE (YEAR(date) = :y AND MONTH(date) >= :sm AND MONTH(date) <= :em) ORDER BY date ASC");
|
||||
$stmt->execute(['y' => $year, 'sm' => $start_month, 'em' => $end_month]);
|
||||
$data = $stmt->fetchAll();
|
||||
|
||||
if ($format === 'excel') {
|
||||
header("Content-type: application/vnd.ms-excel");
|
||||
header("Content-Disposition: attachment; filename=Schedule_Export_$year.xls");
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Export Schedule Data</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
table, th, td { border: 1px solid #000; }
|
||||
th, td { padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 style="text-align: center;">Schedule Data (Activities)</h2>
|
||||
<h4 style="text-align: center;">Period: <?= $period_title ?></h4>
|
||||
<p style="text-align: center; font-style: italic;">Export Data Activity Description Room</p>
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:50px; text-align:center;">No</th>
|
||||
<th style="width:150px;">Date</th>
|
||||
<th style="width:100px;">Time</th>
|
||||
<th>Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$no = 1;
|
||||
if(count($data) > 0):
|
||||
foreach($data as $row):
|
||||
$dt = new DateTime($row['date']);
|
||||
?>
|
||||
<tr>
|
||||
<td style="text-align:center;"><?= $no++ ?></td>
|
||||
<td><?= $dt->format('d M Y') ?></td>
|
||||
<td><?= $dt->format('H:i') ?></td>
|
||||
<td><?= htmlspecialchars($row['kegiatan']) ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
endforeach;
|
||||
else:
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="4" style="text-align:center;">No activity data for this period.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php if ($format === 'pdf'): ?>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,83 @@
|
|||
import os
|
||||
import re
|
||||
import json
|
||||
|
||||
base_dir = r"c:\laragon\www\fiberid"
|
||||
php_files = []
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for f in files:
|
||||
if f.endswith('.php'):
|
||||
php_files.append(os.path.join(root, f))
|
||||
|
||||
# A script to extract potential UI strings
|
||||
ui_strings = set()
|
||||
|
||||
# Regexes
|
||||
inner_text_re = re.compile(r'>\s*([^<]+?)\s*<')
|
||||
placeholder_re = re.compile(r'placeholder=[\"\'\']([^\"\'\']+)')
|
||||
title_re = re.compile(r'title=[\"\'\']([^\"\'\']+)')
|
||||
alert_re_1 = re.compile(r'showCustomAlert\(\s*[\"\']([^\"\']+)[\"\']\s*,\s*[\"\']([^\"\']+)[\"\']')
|
||||
|
||||
# Basic Indonesian words to help filter out programming artifacts and english defaults
|
||||
indonesian_words = {
|
||||
'ada', 'adalah', 'akan', 'akun', 'alami', 'ambil', 'anda', 'apa', 'apakah', 'atas', 'atau', 'bagaimana',
|
||||
'batal', 'baru', 'bawah', 'beberapa', 'belakang', 'belum', 'berada', 'berhasil', 'berikut', 'berkas', 'berubah',
|
||||
'besar', 'bisa', 'boleh', 'bukan', 'buat', 'buku', 'butuh', 'cahaya', 'cepat', 'coba', 'cocok', 'contoh', 'dalam',
|
||||
'dan', 'dapat', 'dari', 'data', 'dengan', 'depan', 'di', 'dia', 'diperbarui', 'edit', 'email', 'foto', 'gagal',
|
||||
'gambar', 'ganti', 'gelap', 'hapus', 'hari', 'harus', 'hasil', 'hati', 'hidup', 'hingga', 'hubungi', 'identitas',
|
||||
'informasi', 'ini', 'isi', 'itu', 'jadwal', 'jam', 'jangan', 'jaringan', 'jika', 'juga', 'kalian', 'kami', 'kamera',
|
||||
'kamu', 'kanan', 'karena', 'kata', 'ke', 'kecil', 'kembali', 'kemudian', 'kiri', 'koneksi', 'konfirmasi', 'kontak',
|
||||
'kuat', 'kurang', 'lalu', 'lama', 'lanjutkan', 'lebar', 'lebih', 'lemah', 'lengkap', 'lihat', 'log', 'luar',
|
||||
'masuk', 'masukkan', 'mata', 'melakukan', 'memilih', 'memiliki', 'mencari', 'mendeteksi', 'menggunakan', 'mengisi',
|
||||
'mengubah', 'mengunggah', 'menunggu', 'merubah', 'mereka', 'minimal', 'mula', 'mulai', 'muncul', 'nama', 'ni',
|
||||
'nomor', 'oleh', 'opsi', 'pada', 'paling', 'panjang', 'password', 'pastikan', 'pencahayaan', 'pendaftaran',
|
||||
'pengaturan', 'pengelola', 'pengguna', 'penghuni', 'penting', 'percobaan', 'perbarui', 'peringatan', 'perlu',
|
||||
'pilih', 'pin', 'posisi', 'posisikan', 'pribadi', 'profil', 'proses', 'riwayat', 'salah', 'sama', 'sambut', 'sampai',
|
||||
'sana', 'sandi', 'sangat', 'saat', 'saya', 'sebagai', 'sebelum', 'sebelumnya', 'secara', 'sedang', 'segera', 'selamat',
|
||||
'selanjutnya', 'selesai', 'seluruh', 'semua', 'seperti', 'sesuai', 'setelah', 'setuju', 'siap', 'silakan', 'silahkan',
|
||||
'simpan', 'sini', 'sistem', 'status', 'sudah', 'tambah', 'tambahan', 'tanpa', 'tata', 'telah', 'tempat', 'tentang',
|
||||
'terang', 'terbaru', 'terdaftar', 'terdeteksi', 'terhadap', 'terjadi', 'terlalu', 'tersebut', 'tersedia', 'tetapi',
|
||||
'tidak', 'tinggal', 'tolak', 'tolong', 'tutup', 'ubah', 'ukuran', 'ulang', 'umum', 'unggah', 'untuk', 'utara',
|
||||
'wajah', 'waktu', 'wajib', 'warna', 'ya', 'yang'
|
||||
}
|
||||
|
||||
def is_likely_indonesian(text):
|
||||
words = set(re.findall(r'[a-zA-Z]+', text.lower()))
|
||||
return len(words.intersection(indonesian_words)) > 0
|
||||
|
||||
def clean_extracts(items):
|
||||
valid = []
|
||||
for item in items:
|
||||
# Strip outer spaces
|
||||
item = item.strip()
|
||||
# Skip empty or very short
|
||||
if len(item) < 3: continue
|
||||
# Skip if it's purely php tags or html artifacts
|
||||
if '<?' in item or '?>' in item or item.startswith('$') or item.startswith(';') or item.startswith('{'): continue
|
||||
if is_likely_indonesian(item):
|
||||
valid.append(item)
|
||||
return valid
|
||||
|
||||
for file in php_files:
|
||||
try:
|
||||
with open(file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except:
|
||||
pass
|
||||
|
||||
found = []
|
||||
found.extend(inner_text_re.findall(content))
|
||||
found.extend(placeholder_re.findall(content))
|
||||
found.extend(title_re.findall(content))
|
||||
|
||||
for t1, t2 in alert_re_1.findall(content):
|
||||
found.extend([t1, t2])
|
||||
|
||||
valid_strings = clean_extracts(found)
|
||||
for s in valid_strings:
|
||||
ui_strings.add(s)
|
||||
|
||||
with open(r'c:\laragon\www\fiberid\indonesian_strings.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(list(ui_strings), f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"Extracted {len(ui_strings)} unique Indonesian strings")
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
|
||||
$id_account = $_GET['id_account'] ?? null;
|
||||
if (!$id_account) {
|
||||
header("Location: register.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
$differentDeviceUrl = '';
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT finger_id, code_register FROM account WHERE id_account = ?");
|
||||
$stmt->execute([$id_account]);
|
||||
$acc = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$acc) {
|
||||
header("Location: register.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!empty($acc['finger_id'])) {
|
||||
$error = "Fingerprint for this account has already been registered!";
|
||||
} else {
|
||||
// Generate/retrieve code_register and build URL for scanning on different device
|
||||
$code = $acc['code_register'] ?? null;
|
||||
if (!$code) {
|
||||
$code = sprintf("%06d", mt_rand(1, 999999));
|
||||
$update = $pdo->prepare("UPDATE account SET code_register = ? WHERE id_account = ?");
|
||||
$update->execute([$code, $id_account]);
|
||||
}
|
||||
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http";
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$uriPath = dirname($_SERVER['REQUEST_URI']);
|
||||
if ($uriPath == '/' || $uriPath == '\\') $uriPath = '';
|
||||
$differentDeviceUrl = $protocol . "://" . $host . $uriPath . "/scan_here.php?token=" . $code;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$error = "A database error occurred.";
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Register Passkey - Identia</title>
|
||||
<link rel="stylesheet" href="vendors/feather/feather.css">
|
||||
<link rel="stylesheet" href="vendors/ti-icons/css/themify-icons.css">
|
||||
<link rel="stylesheet" href="vendors/css/vendor.bundle.base.css">
|
||||
<link rel="stylesheet" href="css/vertical-layout-light/style.css">
|
||||
<link rel="shortcut icon" href="images/myn.png" />
|
||||
<style>
|
||||
/* Custom styles for centered layout */
|
||||
.auth-form-light {
|
||||
text-align: center;
|
||||
}
|
||||
.brand-logo {
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.custom-alert {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #fff;
|
||||
border-left: 4px solid #fcd34d;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 15px 20px;
|
||||
border-radius: 4px;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 300px;
|
||||
<?php
|
||||
session_start();
|
||||
include 'koneksi.php';
|
||||
|
||||
$id_account = $_GET['id_account'] ?? null;
|
||||
if (!$id_account) {
|
||||
header("Location: register.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
$differentDeviceUrl = '';
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT finger_id, code_register FROM account WHERE id_account = ?");
|
||||
$stmt->execute([$id_account]);
|
||||
$acc = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$acc) {
|
||||
header("Location: register.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!empty($acc['finger_id'])) {
|
||||
$error = "Fingerprint for this account has already been registered!";
|
||||
} else {
|
||||
// Generate/retrieve code_register and build URL for scanning on different device
|
||||
$code = $acc['code_register'] ?? null;
|
||||
if (!$code) {
|
||||
$code = sprintf("%06d", mt_rand(1, 999999));
|
||||
$update = $pdo->prepare("UPDATE account SET code_register = ? WHERE id_account = ?");
|
||||
$update->execute([$code, $id_account]);
|
||||
}
|
||||
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http";
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$uriPath = dirname($_SERVER['REQUEST_URI']);
|
||||
if ($uriPath == '/' || $uriPath == '\\') $uriPath = '';
|
||||
$differentDeviceUrl = $protocol . "://" . $host . $uriPath . "/scan_here.php?token=" . $code;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$error = "A database error occurred.";
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Register Passkey - Identia Admin</title>
|
||||
<link rel="stylesheet" href="vendors/feather/feather.css">
|
||||
<link rel="stylesheet" href="vendors/ti-icons/css/themify-icons.css">
|
||||
<link rel="stylesheet" href="vendors/css/vendor.bundle.base.css">
|
||||
<link rel="stylesheet" href="css/vertical-layout-light/style.css">
|
||||
<link rel="shortcut icon" href="images/myn.png" />
|
||||
<style>
|
||||
/* Custom styles for centered layout */
|
||||
.auth-form-light {
|
||||
text-align: center;
|
||||
}
|
||||
.brand-logo {
|
||||
text-align: left;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.custom-alert {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #fff;
|
||||
border-left: 4px solid #fcd34d;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 15px 20px;
|
||||
border-radius: 4px;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
.custom-alert.error { border-left-color: #ef4444; }
|
||||
.custom-alert.success { border-left-color: #10b981; }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
.custom-alert-icon { font-size: 24px; }
|
||||
.custom-alert.error .custom-alert-icon { color: #ef4444; }
|
||||
.custom-alert.success .custom-alert-icon { color: #10b981; }
|
||||
.custom-alert.warning .custom-alert-icon { color: #f59e0b; }
|
||||
.custom-alert-content { flex: 1; text-align: left; }
|
||||
.custom-alert-title { font-weight: bold; font-size: 14px; margin-bottom: 2px; color: #333; }
|
||||
.custom-alert-msg { font-size: 13px; color: #666; line-height: 1.4; }
|
||||
.custom-alert-close { cursor: pointer; color: #999; font-size: 18px; }
|
||||
.custom-alert-close:hover { color: #333; }
|
||||
|
||||
body, .container-scroller, .page-body-wrapper, .content-wrapper {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
#rightSideBg { padding: 6rem 5rem; min-height: 100vh; }
|
||||
@media (max-width: 991px) {
|
||||
#rightSideBg { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid p-0">
|
||||
<div class="row m-0 w-100" style="min-height: 100vh;">
|
||||
<!-- Left Side: Form -->
|
||||
<div class="col-12 col-lg-6 d-flex flex-column justify-content-center bg-white" style="z-index: 10; box-shadow: 2px 0 15px rgba(0,0,0,0.1); padding: 4rem 10%; overflow-y: auto; max-height: 100vh;">
|
||||
<div class="auth-form-light text-left py-0 px-0 rounded">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<img src="images/myn.png" alt="logo" style="height: 48px; filter: brightness(0); margin-right: 15px;">
|
||||
<div>
|
||||
<h2 class="font-weight-bold text-dark mb-1" style="font-size: 2rem; line-height: 1.2;">Enable Touch ID</h2>
|
||||
<p class="font-weight-light text-muted mb-0" style="font-size: 1rem;">Register your fingerprint for faster login.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if($error): ?>
|
||||
<!-- Error will be shown via custom alert in script -->
|
||||
<div class="mt-3">
|
||||
<a href="login.php" class="btn btn-outline-primary btn-block">Cancel / Go to Login</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<button type="button" id="registerFinger" class="btn btn-block btn-lg text-white font-weight-medium mb-3" style="background-color: #4B49AC; border-radius: 8px; padding: 0.8rem;">
|
||||
Register fingerprint
|
||||
</button>
|
||||
|
||||
<div class="mt-4 pt-4 border-top text-left">
|
||||
<label class="font-weight-medium text-muted mb-2" style="font-size: 12px; display: block;">
|
||||
Or scan fingerprint using a different device:
|
||||
</label>
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" class="form-control form-control-sm" id="differentDeviceLink" readonly value="<?= htmlspecialchars($differentDeviceUrl) ?>" style="font-size:11px; padding:4px; border-top-right-radius:0 !important; border-bottom-right-radius:0 !important;">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-light text-dark btn-sm" type="button" onclick="copyDifferentDeviceLink()" style="padding:4px 10px; border-top-left-radius:0 !important; border-bottom-left-radius:0 !important;"><i class="mdi mdi-content-copy mr-1"></i>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-top">
|
||||
<a href="login.php" style="text-decoration: underline; font-weight: 600; color: #101010ff; font-size: 14px; display: inline-block; transition: color 0.2s;" onmouseover="this.style.color='#4B49AC'" onmouseout="this.style.color='#6c757d'">Skip</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Side: Highly Innovative Tech Background -->
|
||||
<div id="rightSideBg" class="col-lg-6 d-none d-lg-flex flex-column align-items-center justify-content-center position-relative" style="background-color: #4B49AC; overflow: hidden; padding: 6rem 5rem;">
|
||||
|
||||
<!-- Animated Tech Rings -->
|
||||
<div style="position: absolute; top: -20%; right: -20%; width: 800px; height: 800px; border: 1px dashed rgba(255,255,255,0.15); border-radius: 50%; animation: spinSlow 60s linear infinite; z-index: 0; pointer-events: none;">
|
||||
<div style="position: absolute; top: 10%; left: 10%; width: 80%; height: 80%; border: 1px solid rgba(255,255,255,0.05); border-radius: 50%; animation: spinReverse 40s linear infinite;">
|
||||
<div style="position: absolute; top: -5px; left: 50%; width: 10px; height: 10px; background: rgba(255,255,255,0.8); border-radius: 50%; box-shadow: 0 0 15px rgba(255,255,255,1);"></div>
|
||||
</div>
|
||||
<div style="position: absolute; top: 25%; left: 25%; width: 50%; height: 50%; border: 2px solid rgba(255,255,255,0.03); border-radius: 50%;"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spinSlow { 100% { transform: rotate(360deg); } }
|
||||
@keyframes spinReverse { 100% { transform: rotate(-360deg); } }
|
||||
</style>
|
||||
|
||||
<!-- Interactive Particle Canvas -->
|
||||
<canvas id="particleCanvas" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: auto;"></canvas>
|
||||
|
||||
<!-- Script for Interactive Particles -->
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const canvas = document.getElementById("particleCanvas");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
let particlesArray;
|
||||
|
||||
function resize() {
|
||||
canvas.width = canvas.parentElement.clientWidth;
|
||||
canvas.height = canvas.parentElement.clientHeight;
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
let mouse = { x: null, y: null, radius: 120 };
|
||||
canvas.addEventListener('mousemove', function(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = event.clientX - rect.left;
|
||||
mouse.y = event.clientY - rect.top;
|
||||
});
|
||||
canvas.addEventListener('mouseleave', function() {
|
||||
mouse.x = null;
|
||||
mouse.y = null;
|
||||
});
|
||||
|
||||
class Particle {
|
||||
constructor(x, y, directionX, directionY, size, color) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.directionX = directionX;
|
||||
this.directionY = directionY;
|
||||
this.size = size;
|
||||
this.color = color;
|
||||
}
|
||||
draw() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2, false);
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.fill();
|
||||
}
|
||||
update() {
|
||||
if (this.x > canvas.width || this.x < 0) this.directionX = -this.directionX;
|
||||
if (this.y > canvas.height || this.y < 0) this.directionY = -this.directionY;
|
||||
|
||||
if (mouse.x != null && mouse.y != null) {
|
||||
let dx = mouse.x - this.x;
|
||||
let dy = mouse.y - this.y;
|
||||
let distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < mouse.radius + this.size) {
|
||||
if (mouse.x < this.x && this.x < canvas.width - this.size * 10) this.x += 1;
|
||||
if (mouse.x > this.x && this.x > this.size * 10) this.x -= 1;
|
||||
if (mouse.y < this.y && this.y < canvas.height - this.size * 10) this.y += 1;
|
||||
if (mouse.y > this.y && this.y > this.size * 10) this.y -= 1;
|
||||
}
|
||||
}
|
||||
this.x += this.directionX;
|
||||
this.y += this.directionY;
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
particlesArray = [];
|
||||
let numberOfParticles = (canvas.height * canvas.width) / 10000;
|
||||
for (let i = 0; i < numberOfParticles; i++) {
|
||||
let size = (Math.random() * 2) + 0.5;
|
||||
let x = (Math.random() * ((canvas.width - size * 2) - (size * 2)) + size * 2);
|
||||
let y = (Math.random() * ((canvas.height - size * 2) - (size * 2)) + size * 2);
|
||||
let directionX = (Math.random() * 1) - 0.5;
|
||||
let directionY = (Math.random() * 1) - 0.5;
|
||||
let color = 'rgba(255,255,255,0.4)';
|
||||
particlesArray.push(new Particle(x, y, directionX, directionY, size, color));
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
for (let a = 0; a < particlesArray.length; a++) {
|
||||
for (let b = a; b < particlesArray.length; b++) {
|
||||
let distance = ((particlesArray[a].x - particlesArray[b].x) * (particlesArray[a].x - particlesArray[b].x)) +
|
||||
((particlesArray[a].y - particlesArray[b].y) * (particlesArray[a].y - particlesArray[b].y));
|
||||
if (distance < (canvas.width / 7) * (canvas.height / 7)) {
|
||||
let opacityValue = 1 - (distance / 20000);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,' + opacityValue * 0.4 + ')';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particlesArray[a].x, particlesArray[a].y);
|
||||
ctx.lineTo(particlesArray[b].x, particlesArray[b].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (let i = 0; i < particlesArray.length; i++) {
|
||||
particlesArray[i].update();
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
init();
|
||||
animate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Sliding Text Container -->
|
||||
<div style="z-index: 2; max-width: 600px; color: #fff; pointer-events: none;">
|
||||
<div id="loginTextSlider" class="carousel slide" data-ride="carousel" data-interval="4000" style="pointer-events: auto;">
|
||||
<div class="carousel-inner">
|
||||
<div class="carousel-item active">
|
||||
<h1 class="font-weight-bold mb-4" style="font-size: 4rem; letter-spacing: 0px; text-shadow: 0 4px 10px rgba(0,0,0,0.1);">Welcome to...</h1>
|
||||
<p style="font-size: 1.15rem; opacity: 0.85; font-weight: 300; line-height: 1.6;">Experience the best way to manage your work and track user activities efficiently with our state-of-the-art platform.</p>
|
||||
</div>
|
||||
<div class="carousel-item">
|
||||
<h1 class="font-weight-bold mb-4" style="font-size: 4rem; letter-spacing: 0px; text-shadow: 0 4px 10px rgba(0,0,0,0.1);">Secure Access</h1>
|
||||
<p style="font-size: 1.15rem; opacity: 0.85; font-weight: 300; line-height: 1.6;">Your data is protected with military-grade security encryption and seamless advanced biometric login features.</p>
|
||||
</div>
|
||||
<div class="carousel-item">
|
||||
<h1 class="font-weight-bold mb-4" style="font-size: 4rem; letter-spacing: 0px; text-shadow: 0 4px 10px rgba(0,0,0,0.1);">Deep Analytics</h1>
|
||||
<p style="font-size: 1.15rem; opacity: 0.85; font-weight: 300; line-height: 1.6;">Get detailed insights, real-time history logs, and actionable metrics to make informed administrative decisions instantly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Indicators aligned to the left -->
|
||||
<ol class="carousel-indicators" style="position: relative; margin-top: 3rem; margin-bottom: 0; justify-content: flex-start; margin-left: 0;">
|
||||
<li data-target="#loginTextSlider" data-slide-to="0" class="active" style="width: 10px; height: 10px; border-radius: 50%; margin: 0 8px 0 0; background-color: #fff; opacity: 0.5; border: none;"></li>
|
||||
<li data-target="#loginTextSlider" data-slide-to="1" style="width: 10px; height: 10px; border-radius: 50%; margin: 0 8px 0 0; background-color: #fff; opacity: 0.5; border: none;"></li>
|
||||
<li data-target="#loginTextSlider" data-slide-to="2" style="width: 10px; height: 10px; border-radius: 50%; margin: 0 8px 0 0; background-color: #fff; opacity: 0.5; border: none;"></li>
|
||||
</ol>
|
||||
<style>
|
||||
.carousel-indicators .active { opacity: 1 !important; transform: scale(1.2); }
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Custom Alert Container -->
|
||||
<div id="customAlert" class="custom-alert warning">
|
||||
<i class="mdi mdi-alert-circle custom-alert-icon" id="customAlertIcon"></i>
|
||||
<div class="custom-alert-content">
|
||||
<div class="custom-alert-title" id="customAlertTitle">Warning</div>
|
||||
<div class="custom-alert-msg" id="customAlertMsg">Message goes here</div>
|
||||
</div>
|
||||
<i class="mdi mdi-close custom-alert-close" onclick="closeCustomAlert()"></i>
|
||||
</div>
|
||||
|
||||
<script src="vendors/js/vendor.bundle.base.js"></script>
|
||||
<script src="js/off-canvas.js"></script>
|
||||
<script src="js/hoverable-collapse.js"></script>
|
||||
<script src="js/template.js"></script>
|
||||
<script>
|
||||
function showCustomAlert(title, message, type='warning') {
|
||||
const alertBox = document.getElementById('customAlert');
|
||||
const icon = document.getElementById('customAlertIcon');
|
||||
|
||||
alertBox.className = 'custom-alert ' + type;
|
||||
if(type === 'error') icon.className = 'mdi mdi-close-circle custom-alert-icon';
|
||||
else if(type === 'success') icon.className = 'mdi mdi-check-circle custom-alert-icon';
|
||||
else icon.className = 'mdi mdi-alert-circle custom-alert-icon';
|
||||
|
||||
document.getElementById('customAlertTitle').innerText = title;
|
||||
document.getElementById('customAlertMsg').innerText = message;
|
||||
|
||||
alertBox.style.display = 'flex';
|
||||
alertBox.style.animation = 'slideIn 0.3s ease-out';
|
||||
|
||||
setTimeout(() => closeCustomAlert(), 5000); // auto close after 5s
|
||||
}
|
||||
|
||||
function closeCustomAlert() {
|
||||
const alertBox = document.getElementById('customAlert');
|
||||
alertBox.style.animation = 'fadeOut 0.3s ease-out forwards';
|
||||
setTimeout(() => {
|
||||
alertBox.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function copyDifferentDeviceLink() {
|
||||
const linkInput = document.getElementById('differentDeviceLink');
|
||||
if (linkInput && linkInput.value) {
|
||||
navigator.clipboard.writeText(linkInput.value).then(() => {
|
||||
showCustomAlert("Success", "Link copied to clipboard successfully!", "success");
|
||||
}).catch(err => {
|
||||
console.error("Failed to copy link: ", err);
|
||||
showCustomAlert("Error", "Failed to copy link.", "error");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
<?php if(!$error): ?>
|
||||
document.getElementById('registerFinger').addEventListener('click', async () => {
|
||||
try {
|
||||
if (!window.PublicKeyCredential) {
|
||||
showCustomAlert("Not Supported", "This browser does not support WebAuthn (Passkey / Fingerprint).", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
<?php
|
||||
$stmtEx = $pdo->query("SELECT finger_id FROM account WHERE finger_id IS NOT NULL AND finger_id != ''");
|
||||
$allCredsArray = $stmtEx->fetchAll(PDO::FETCH_COLUMN);
|
||||
?>
|
||||
const allExistingCreds = <?= json_encode($allCredsArray) ?>;
|
||||
const excludeList = allExistingCreds.map(idBase64 => ({
|
||||
type: "public-key",
|
||||
id: Uint8Array.from(atob(idBase64), c => c.charCodeAt(0))
|
||||
}));
|
||||
|
||||
const publicKey = {
|
||||
challenge: new Uint8Array(32),
|
||||
excludeCredentials: excludeList,
|
||||
rp: {
|
||||
name: "Identia App",
|
||||
id: window.location.hostname
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from("uid_<?= $id_account ?>", c => c.charCodeAt(0)),
|
||||
name: "Identia_user",
|
||||
displayName: "Identia_user"
|
||||
},
|
||||
pubKeyCredParams: [{type: "public-key", alg: -7}, {type: "public-key", alg: -257}],
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: "platform",
|
||||
residentKey: "required",
|
||||
userVerification: "required"
|
||||
},
|
||||
timeout: 60000,
|
||||
attestation: "none"
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
// Cek ketersediaan credential
|
||||
if(credential) {
|
||||
const credId = btoa(String.fromCharCode.apply(null, new Uint8Array(credential.rawId)));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('id_account', '<?= $id_account ?>');
|
||||
formData.append('credential_id', credId);
|
||||
|
||||
const res = await fetch('save_fingerprint_account.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
showCustomAlert("Success", "Fingerprint registered successfully. You can use it to login.", "success");
|
||||
setTimeout(() => { window.location.href = 'login.php'; }, 2000);
|
||||
} else {
|
||||
showCustomAlert("Failed", "Server error: " + result.message, "error");
|
||||
}
|
||||
}
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showCustomAlert("Cancelled", "The process was cancelled or the fingerprint could not be read.", "warning");
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
|
||||
// Tampilkan error PHP jika ada menggunakan custom alert
|
||||
<?php if($error): ?>
|
||||
showCustomAlert("Attention", "<?= addslashes(htmlspecialchars($error)) ?>", "warning");
|
||||
<?php endif; ?>
|
||||
|
||||
// Enforce Notification Permission
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if ('Notification' in window && Notification.permission !== 'granted') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flowchart Alat ESP32 Identia</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
size: portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1, h2 {
|
||||
text-align: center;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Flowchart Sistem Alat (ESP32)</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="mermaid">
|
||||
flowchart TD
|
||||
Mulai([Mulai / Alat Dinyalakan]) --> Init[Inisialisasi Sistem & Koneksi WiFi]
|
||||
Init --> Standby[Layar Utama / Standby<br>SCR_MAIN]
|
||||
|
||||
%% Dua jalur utama saat di layar Standby
|
||||
Standby -->|Tempelkan Jari| BacaJari[Sensor Membaca Sidik Jari]
|
||||
Standby -->|Sentuh Layar| Menu[Layar Daftar Menu<br>SCR_MENU_LIST]
|
||||
|
||||
%% Alur Validasi Sidik Jari (Akses Masuk)
|
||||
BacaJari --> Validasi{Cek ID Jari<br>Aktif & Terdaftar?}
|
||||
Validasi -->|Benar| AksesBuka[Pintu Terbuka]
|
||||
Validasi -->|Salah| AksesTolak[Tampilkan Layar Akses Ditolak<br>Buzzer Bunyi Gagal]
|
||||
AksesTolak --> Standby
|
||||
|
||||
AksesBuka --> Sinkronisasi[Kirim Laporan Absensi ke Server<br>Sync Task]
|
||||
Sinkronisasi --> StatusKoneksi{Apakah Koneksi<br>Internet Ada?}
|
||||
StatusKoneksi -->|Online| QRReport[Tampilkan Layar QR Laporan<br>SCR_QR_REPORT]
|
||||
StatusKoneksi -->|Offline| FormOffline[Tampilkan Formulir Aktivitas Offline<br>SCR_ACTIVITY_FORM]
|
||||
|
||||
QRReport --> SelesaiAbsen([Selesai])
|
||||
FormOffline --> SimpanOffline[Simpan Log di Memori Alat<br>NVS]
|
||||
SimpanOffline --> SelesaiAbsen
|
||||
SelesaiAbsen -->|Timeout/Tutup Pintu| Standby
|
||||
|
||||
%% Alur Navigasi Menu
|
||||
Menu --> PilihDaftar[Pilih: Registrasi Baru]
|
||||
Menu --> PilihHelpdesk[Pilih: Bantuan / Helpdesk]
|
||||
Menu --> PilihDarurat[Pilih: Keadaan Darurat]
|
||||
|
||||
%% Alur Bantuan
|
||||
PilihHelpdesk --> LayarHelpdesk[Tampilkan Info Bantuan<br>SCR_HELPDESK]
|
||||
LayarHelpdesk -->|Kembali| Menu
|
||||
|
||||
%% Alur Keadaan Darurat
|
||||
PilihDarurat --> LayarDarurat[Tampilkan Peringatan Darurat<br>SCR_EMERGENCY]
|
||||
LayarDarurat --> InputDarurat[Input Nomor HP/PIN Darurat<br>SCR_EMERGENCY_HP]
|
||||
InputDarurat --> CekDarurat{Verifikasi PIN/HP<br>Darurat}
|
||||
CekDarurat -->|Benar| AksesBuka
|
||||
CekDarurat -->|Salah| AksesTolak
|
||||
|
||||
%% Alur Pendaftaran Baru (Enrollment)
|
||||
PilihDaftar --> MenuDaftar[Layar Menu Pendaftaran<br>SCR_REGISTER_MENU]
|
||||
MenuDaftar --> InputData[Ketik Keyboard Data Diri:<br>Nama, NIK, No HP, PIN]
|
||||
InputData --> AmbilFoto[Layar QR Code Untuk Foto<br>SCR_QR_REG_FOTO]
|
||||
AmbilFoto --> DaftarJari[Layar Pendaftaran Sidik Jari<br>SCR_REG_FINGER]
|
||||
DaftarJari --> RekamJari[Tempel Jari 2x ke Sensor]
|
||||
RekamJari --> CekRekam{Perekaman<br>Berhasil?}
|
||||
CekRekam -->|Gagal| DaftarJari
|
||||
CekRekam -->|Sukses| KirimData[Kirim Data Registrasi ke Server]
|
||||
KirimData --> SelesaiDaftar([Selesai Pendaftaran])
|
||||
SelesaiDaftar --> Standby
|
||||
|
||||
%% Tambahan Kondisi Akun
|
||||
Validasi -->|Akun Diblokir| AkunMati[Layar Akun Dinonaktifkan<br>SCR_ACCESS_DEACTIVATED]
|
||||
AkunMati --> Standby
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'base',
|
||||
securityLevel: 'loose',
|
||||
themeVariables: {
|
||||
lineColor: '#000000',
|
||||
textColor: '#000000',
|
||||
primaryColor: '#ffffff',
|
||||
primaryBorderColor: '#000000',
|
||||
edgeLabelBackground: '#ffffff'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# Flowchart Alat (ESP32)
|
||||
|
||||
Berikut adalah flowchart lengkap khusus untuk alur kerja dan navigasi pada alat (ESP32 + Layar TFT + Sensor Sidik Jari) berdasarkan logika program di `main.cpp`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Mulai([Mulai / Alat Dinyalakan]) --> Init[Inisialisasi Sistem & Koneksi WiFi]
|
||||
Init --> Standby[Layar Utama / Standby\nSCR_MAIN]
|
||||
|
||||
%% Dua jalur utama saat di layar Standby
|
||||
Standby -->|Tempelkan Jari| BacaJari[Sensor Membaca Sidik Jari]
|
||||
Standby -->|Sentuh Layar| Menu[Layar Daftar Menu\nSCR_MENU_LIST]
|
||||
|
||||
%% Alur Validasi Sidik Jari (Akses Masuk)
|
||||
BacaJari --> Validasi{Cek ID Jari\nAktif & Terdaftar?}
|
||||
Validasi -->|Benar| AksesBuka[Pintu Terbuka]
|
||||
Validasi -->|Salah| AksesTolak[Tampilkan Layar Akses Ditolak\nBuzzer Bunyi Gagal]
|
||||
AksesTolak --> Standby
|
||||
|
||||
AksesBuka --> Sinkronisasi[Kirim Laporan Absensi ke Server\nSync Task]
|
||||
Sinkronisasi --> StatusKoneksi{Apakah Koneksi\nInternet Ada?}
|
||||
StatusKoneksi -->|Online| QRReport[Tampilkan Layar QR Laporan\nSCR_QR_REPORT]
|
||||
StatusKoneksi -->|Offline| FormOffline[Tampilkan Formulir Aktivitas Offline\nSCR_ACTIVITY_FORM]
|
||||
|
||||
QRReport --> SelesaiAbsen([Selesai])
|
||||
FormOffline --> SimpanOffline[Simpan Log di Memori Alat\nNVS]
|
||||
SimpanOffline --> SelesaiAbsen
|
||||
SelesaiAbsen -->|Timeout/Tutup Pintu| Standby
|
||||
|
||||
%% Alur Navigasi Menu
|
||||
Menu --> PilihDaftar[Pilih: Registrasi Baru]
|
||||
Menu --> PilihHelpdesk[Pilih: Bantuan / Helpdesk]
|
||||
Menu --> PilihDarurat[Pilih: Keadaan Darurat]
|
||||
|
||||
%% Alur Bantuan
|
||||
PilihHelpdesk --> LayarHelpdesk[Tampilkan Info Bantuan\nSCR_HELPDESK]
|
||||
LayarHelpdesk -->|Kembali| Menu
|
||||
|
||||
%% Alur Keadaan Darurat
|
||||
PilihDarurat --> LayarDarurat[Tampilkan Peringatan Darurat\nSCR_EMERGENCY]
|
||||
LayarDarurat --> InputDarurat[Input Nomor HP/PIN Darurat\nSCR_EMERGENCY_HP]
|
||||
InputDarurat --> CekDarurat{Verifikasi PIN/HP\nDarurat}
|
||||
CekDarurat -->|Benar| AksesBuka
|
||||
CekDarurat -->|Salah| AksesTolak
|
||||
|
||||
%% Alur Pendaftaran Baru (Enrollment)
|
||||
PilihDaftar --> MenuDaftar[Layar Menu Pendaftaran\nSCR_REGISTER_MENU]
|
||||
MenuDaftar --> InputData[Ketik Keyboard Data Diri:\nNama, NIK, No HP, PIN]
|
||||
InputData --> AmbilFoto[Layar QR Code Untuk Foto\nSCR_QR_REG_FOTO]
|
||||
AmbilFoto --> DaftarJari[Layar Pendaftaran Sidik Jari\nSCR_REG_FINGER]
|
||||
DaftarJari --> RekamJari[Tempel Jari 2x ke Sensor]
|
||||
RekamJari --> CekRekam{Perekaman\nBerhasil?}
|
||||
CekRekam -->|Gagal| DaftarJari
|
||||
CekRekam -->|Sukses| KirimData[Kirim Data Registrasi ke Server]
|
||||
KirimData --> SelesaiDaftar([Selesai Pendaftaran])
|
||||
SelesaiDaftar --> Standby
|
||||
|
||||
%% Tambahan Kondisi Akun
|
||||
Validasi -->|Akun Diblokir| AkunMati[Layar Akun Dinonaktifkan\nSCR_ACCESS_DEACTIVATED]
|
||||
AkunMati --> Standby
|
||||
```
|
||||
|
||||
### Penjelasan Alur Flowchart Alat (ESP32):
|
||||
|
||||
**1. Kondisi Layar Utama (Standby)**
|
||||
Layar utama (`SCR_MAIN`) adalah pusat kendali saat alat tidak sedang dioperasikan. Dari sini, alat mendeteksi 2 (dua) jenis interaksi:
|
||||
- **Jari Ditempelkan** langsung ke sensor tanpa menekan layar.
|
||||
- **Layar Disentuh** untuk menavigasi menu.
|
||||
|
||||
**2. Alur Akses (Buka Pintu)**
|
||||
- Saat jari ditempel, sistem mengecek ke *local storage* alat apakah ID jari tersebut aktif dan berhak masuk.
|
||||
- Jika **Gagal/Ditolak/Diblokir**, alat akan menyalakan lampu merah, buzzer berbunyi, dan menampilkan peringatan.
|
||||
- Jika **Berhasil**, alat membuka *relay* (pintu). Setelah pintu terbuka, alat akan mengirimkan data *sync* ke server.
|
||||
- Setelah berhasil terbuka, pengguna diminta melakukan absensi. Jika ada internet, muncul **QR Code**. Jika WiFi putus (offline), akan muncul **Formulir Aktivitas** di layar sentuh untuk diisi manual yang akan tersimpan sementara di memori internal ESP32 (NVS).
|
||||
|
||||
**3. Alur Menu & Fitur Lain**
|
||||
Bila pengguna menyentuh layar, ia akan diarahkan ke beberapa opsi:
|
||||
- **Bantuan (Helpdesk):** Hanya menampilkan informasi kontak atau bantuan standar.
|
||||
- **Darurat (Emergency):** Meminta pengguna memasukkan Nomor HP atau PIN Cadangan Darurat apabila terjadi kendala pada sidik jari untuk memaksa pintu terbuka.
|
||||
- **Registrasi Penghuni Baru:** Ini adalah proses berantai yang paling panjang di alat:
|
||||
1. Pengguna mengetikkan data pribadi (Nama, NIK, Nomor HP, PIN) di keyboard virtual yang muncul pada layar TFT.
|
||||
2. Sistem lalu memunculkan QR Code khusus pendaftaran foto.
|
||||
3. Setelah selesai, masuk ke pendaftaran Sidik Jari. Pengguna harus menempelkan jari 2x ke sensor untuk membaca guratan jari.
|
||||
4. Bila berhasil direkam di alat, data ID dan biodata akan dilempar (POST) ke server via API untuk disimpan sebagai Penghuni baru.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue