Jadwal 2 mode, reset di di box, test button ampli on, dan bel manual

This commit is contained in:
2025-12-21 23:10:59 +08:00
parent cd4ece14b3
commit 034ad6f9a3
3 changed files with 622 additions and 176 deletions

View File

@@ -1,65 +1,245 @@
<!-- data/index.html --> <!-- data/index.html -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Bel Sekolah</title> <title>Bel Sekolah</title>
<style> <style>
/* Inline Tailwind CSS for offline compatibility */ /* Inline Tailwind CSS for offline compatibility */
.bg-gradient-to-b { background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); } .bg-gradient-to-b {
.from-slate-50 { --tw-gradient-from: #f8fafc; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(248, 250, 252, 0)); } background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
.to-white { --tw-gradient-to: #ffffff; } }
.min-h-screen { min-height: 100vh; }
.font-sans { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; } .from-slate-50 {
.text-slate-800 { --tw-text-opacity: 1; color: rgb(30 41 59 / var(--tw-text-opacity)); } --tw-gradient-from: #f8fafc;
.max-w-2xl { max-width: 42rem; } --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(248, 250, 252, 0));
.mx-auto { margin-left: auto; margin-right: auto; } }
.p-6 { padding: 1.5rem; }
.text-center { text-align: center; } .to-white {
.mb-6 { margin-bottom: 1.5rem; } --tw-gradient-to: #ffffff;
.text-5xl { font-size: 3rem; line-height: 1; } }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.font-bold { font-weight: 700; } .min-h-screen {
.text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity)); } min-height: 100vh;
.text-sm { font-size: 0.875rem; line-height: 1.25rem; } }
.text-slate-600 { --tw-text-opacity: 1; color: rgb(71 85 105 / var(--tw-text-opacity)); }
.mt-1 { margin-top: 0.25rem; } .font-sans {
.bg-white { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
.shadow { --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-width 0px), var(--tw-ring-shadow), var(--tw-shadow); } }
.rounded-xl { border-radius: 0.75rem; }
.mb-4 { margin-bottom: 1rem; } .text-slate-800 {
.text-lg { font-size: 1.125rem; line-height: 1.75rem; } --tw-text-opacity: 1;
.mb-1 { margin-bottom: 0.25rem; } color: rgb(30 41 59 / var(--tw-text-opacity));
.text-5xl { font-size: 3rem; line-height: 1; } }
.font-extrabold { font-weight: 800; }
.tracking-wider { letter-spacing: 0.05em; } .max-w-2xl {
.text-slate-500 { --tw-text-opacity: 1; color: rgb(100 116 139 / var(--tw-text-opacity)); } max-width: 42rem;
.mt-2 { margin-top: 0.5rem; } }
.flex { display: flex; }
.items-center { align-items: center; } .mx-auto {
.justify-center { justify-content: center; } margin-left: auto;
.gap-4 { gap: 1rem; } margin-right: auto;
.my-3 { margin-top: 0.75rem; margin-bottom: 0.75rem; } }
.w-3 { width: 0.75rem; }
.h-3 { height: 0.75rem; } .p-6 {
.rounded-full { border-radius: 9999px; } padding: 1.5rem;
.bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity)); } }
.text-slate-700 { --tw-text-opacity: 1; color: rgb(51 65 85 / var(--tw-text-opacity)); }
.grid { display: grid; } .text-center {
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } text-align: center;
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.gap-3 { gap: 0.75rem; }
.mt-6 { margin-top: 1.5rem; } .mb-6 {
.max-w-md { max-width: 28rem; } margin-bottom: 1.5rem;
.bg-slate-50 { --tw-bg-opacity: 1; background-color: rgb(248 250 252 / var(--tw-bg-opacity)); } }
.p-3 { padding: 0.75rem; }
.rounded { border-radius: 0.25rem; } .text-5xl {
.h-40 { height: 10rem; } font-size: 3rem;
.overflow-auto { overflow: auto; } line-height: 1;
.text-xs { font-size: 0.75rem; line-height: 1rem; } }
.text-slate-500 { --tw-text-opacity: 1; color: rgb(100 116 139 / var(--tw-text-opacity)); }
.mt-6 { margin-top: 1.5rem; } .text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.font-bold {
font-weight: 700;
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-slate-600 {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
.mt-1 {
margin-top: 0.25rem;
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.shadow {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-width 0px), var(--tw-ring-shadow), var(--tw-shadow);
}
.rounded-xl {
border-radius: 0.75rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.font-extrabold {
font-weight: 800;
}
.tracking-wider {
letter-spacing: 0.05em;
}
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
.mt-2 {
margin-top: 0.5rem;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.gap-4 {
gap: 1rem;
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.w-3 {
width: 0.75rem;
}
.h-3 {
height: 0.75rem;
}
.rounded-full {
border-radius: 9999px;
}
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.text-slate-700 {
--tw-text-opacity: 1;
color: rgb(51 65 85 / var(--tw-text-opacity));
}
.grid {
display: grid;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.gap-3 {
gap: 0.75rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.max-w-md {
max-width: 28rem;
}
.bg-slate-50 {
--tw-bg-opacity: 1;
background-color: rgb(248 250 252 / var(--tw-bg-opacity));
}
.p-3 {
padding: 0.75rem;
}
.rounded {
border-radius: 0.25rem;
}
.h-40 {
height: 10rem;
}
.overflow-auto {
overflow: auto;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
.mt-6 {
margin-top: 1.5rem;
}
/* Button styles */ /* Button styles */
.btn { .btn {
display: inline-block; display: inline-block;
@@ -71,19 +251,46 @@
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
} }
.btn.small { padding: 0.25rem 0.5rem; font-size: 0.9rem; }
.btn.btn-blue { background: #2563EB; } .btn.small {
.btn.btn-green { background: #059669; } padding: 0.25rem 0.5rem;
.btn.btn-red { background: #DC2626; } font-size: 0.9rem;
.btn.btn-gray { background: #6B7280; } }
.btn.btn-purple { background: #7C3AED; }
.btn:hover { filter: brightness(0.95); } .btn.btn-blue {
background: #2563EB;
}
.btn.btn-green {
background: #059669;
}
.btn.btn-red {
background: #DC2626;
}
.btn.btn-gray {
background: #6B7280;
}
.btn.btn-purple {
background: #7C3AED;
}
.btn:hover {
filter: brightness(0.95);
}
/* Responsive */ /* Responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.btn { padding: 0.45rem 0.7rem; font-size: 0.9rem; } .btn {
padding: 0.45rem 0.7rem;
font-size: 0.9rem;
}
} }
</style> </style>
</head> </head>
<body class="bg-gradient-to-b from-slate-50 to-white min-h-screen font-sans text-slate-800"> <body class="bg-gradient-to-b from-slate-50 to-white min-h-screen font-sans text-slate-800">
<div class="max-w-2xl mx-auto p-6"> <div class="max-w-2xl mx-auto p-6">
<header class="text-center mb-6"> <header class="text-center mb-6">
@@ -120,93 +327,96 @@
</footer> </footer>
</div> </div>
<script> <script>
// WebSocket connection (uses same host) // WebSocket connection (uses same host)
const protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'; const protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
const ws = new WebSocket(protocol + location.host + '/ws'); const ws = new WebSocket(protocol + location.host + '/ws');
const hariEl = document.getElementById('hari'); const hariEl = document.getElementById('hari');
const clockEl = document.getElementById('clock'); const clockEl = document.getElementById('clock');
const tanggalEl = document.getElementById('tanggal'); const tanggalEl = document.getElementById('tanggal');
const statusDot = document.getElementById('statusDot'); const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText'); const statusText = document.getElementById('statusText');
const schoolEl = document.getElementById('schoolName'); const schoolEl = document.getElementById('schoolName');
const logEl = document.getElementById('log'); const logEl = document.getElementById('log');
function appendLog(s) { function appendLog(s) {
const t = document.createElement('div'); const t = document.createElement('div');
t.textContent = (new Date()).toLocaleTimeString() + " — " + s; t.textContent = (new Date()).toLocaleTimeString() + " — " + s;
logEl.prepend(t); logEl.prepend(t);
}
ws.onopen = () => {
appendLog("🔌 WebSocket tersambung.");
};
ws.onclose = () => {
appendLog("⚠️ WebSocket terputus.");
statusDot.style.background = 'gray';
statusText.textContent = 'Terputus';
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'time') {
const h = String(msg.jam).padStart(2,'0');
const m = String(msg.menit).padStart(2,'0');
const s = String(msg.detik).padStart(2,'0');
clockEl.textContent = `${h}.${m}.${s}`;
const weekday = ["Minggu","Senin","Selasa","Rabu","Kamis","Jumat","Sabtu"];
hariEl.textContent = weekday[msg.weekday || new Date(msg.epoch*1000).getDay()];
const monthNames = ["Januari","Februari","Maret","April","Mei","Juni","Juli","Agustus","September","Oktober","November","Desember"];
tanggalEl.textContent = `${msg.hari} ${monthNames[(msg.bulan||(new Date(msg.epoch*1000)).getMonth())-1]} ${msg.tahun}`;
} else if (msg.type === 'status') {
if (msg.playing) {
statusDot.style.background = 'green';
statusText.textContent = `Bel berbunyi (Track ${msg.track})`;
} else {
statusDot.style.background = 'gray';
statusText.textContent = 'Bel siaga';
}
if (msg.schoolName) schoolEl.textContent = msg.schoolName;
// Update clients info
const clientsInfoEl = document.getElementById('clientsInfo');
if (msg.clientList && msg.clientList.length > 0) {
let info = '';
msg.clientList.forEach((client, index) => {
if (index > 0) info += ', ';
info += `${client.mac}: ${client.percentage}%`;
});
clientsInfoEl.textContent = info;
} else {
clientsInfoEl.textContent = '';
}
} else if (msg.type === 'log') {
appendLog(msg.msg);
}
} catch(e) {
console.error("ws onmessage parse err", e);
} }
};
// Buttons ws.onopen = () => {
document.getElementById('btnTest').addEventListener('click', async () => { appendLog("🔌 WebSocket tersambung.");
// call protected API -> will prompt basic auth };
// test uses track 1000 (same as button click)
try {
await fetch('/api/play?track=1000');
} catch(e) { console.error(e); }
});
document.getElementById('btnStop').addEventListener('click', async () => { ws.onclose = () => {
try { appendLog("⚠️ WebSocket terputus.");
await fetch('/api/stop'); statusDot.style.background = 'gray';
} catch(e) { console.error(e); } statusText.textContent = 'Terputus';
}); };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'time') {
const h = String(msg.jam).padStart(2, '0');
const m = String(msg.menit).padStart(2, '0');
const s = String(msg.detik).padStart(2, '0');
clockEl.textContent = `${h}.${m}.${s}`;
const weekday = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"];
hariEl.textContent = weekday[msg.weekday || new Date(msg.epoch * 1000).getDay()];
const monthNames = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"];
tanggalEl.textContent = `${msg.hari} ${monthNames[(msg.bulan || (new Date(msg.epoch * 1000)).getMonth()) - 1]} ${msg.tahun}`;
} else if (msg.type === 'status') {
if (msg.playing) {
statusDot.style.background = 'green';
statusText.textContent = `Bel berbunyi (Track ${msg.track})`;
} else {
statusDot.style.background = 'gray';
statusText.textContent = 'Bel siaga';
}
if (msg.schoolName) schoolEl.textContent = msg.schoolName;
// Update clients info
const clientsInfoEl = document.getElementById('clientsInfo');
if (msg.clientList && msg.clientList.length > 0) {
let info = '';
msg.clientList.forEach((client, index) => {
if (index > 0) info += ', ';
info += `${client.mac}: ${client.percentage}%`;
});
clientsInfoEl.textContent = info;
} else {
clientsInfoEl.textContent = '';
}
} else if (msg.type === 'log') {
appendLog(msg.msg);
}
} catch (e) {
console.error("ws onmessage parse err", e);
}
};
// Buttons
document.getElementById('btnTest').addEventListener('click', async () => {
try {
// Fetch testTrack from config first
const configRes = await fetch('/api/config');
const config = await configRes.json();
const track = config.testTrack || 1000;
await fetch(`/api/play?track=${track}`);
} catch (e) { console.error(e); }
});
document.getElementById('btnStop').addEventListener('click', async () => {
try {
await fetch('/api/stop');
} catch (e) { console.error(e); }
});
</script> </script>
</body> </body>
</html>
</html>

View File

@@ -515,6 +515,35 @@
.th-desc { .th-desc {
min-width: 150px; min-width: 150px;
} }
/* Day tabs */
.day-tab {
background-color: transparent;
color: #64748b;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.day-tab:hover {
background-color: #e2e8f0;
}
.day-tab.active {
background-color: #2563eb;
color: white;
}
.day-tab[data-day="0"],
.day-tab[data-day="6"] {
color: #dc2626;
}
.day-tab[data-day="0"].active,
.day-tab[data-day="6"].active {
background-color: #dc2626;
color: white;
}
</style> </style>
</head> </head>
@@ -584,7 +613,7 @@
<!-- Tab: Jadwal --> <!-- Tab: Jadwal -->
<div id="tab-jadwal" class="tab-content block"> <div id="tab-jadwal" class="tab-content block">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-slate-800">Jadwal</h2> <h2 class="text-2xl font-bold text-slate-800">Jadwal</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="tambah()" class="btn btn-green small"> Tambah</button> <button onclick="tambah()" class="btn btn-green small"> Tambah</button>
@@ -592,6 +621,26 @@
</div> </div>
</div> </div>
<!-- Day Tabs (only shown in 7-day mode) -->
<div id="dayTabsContainer" class="hidden mb-4">
<div class="flex flex-wrap gap-1 bg-slate-100 p-1 rounded-lg">
<button onclick="switchDayTab(0)" class="day-tab px-3 py-2 rounded text-sm font-medium"
data-day="0">Minggu</button>
<button onclick="switchDayTab(1)" class="day-tab px-3 py-2 rounded text-sm font-medium active"
data-day="1">Senin</button>
<button onclick="switchDayTab(2)" class="day-tab px-3 py-2 rounded text-sm font-medium"
data-day="2">Selasa</button>
<button onclick="switchDayTab(3)" class="day-tab px-3 py-2 rounded text-sm font-medium"
data-day="3">Rabu</button>
<button onclick="switchDayTab(4)" class="day-tab px-3 py-2 rounded text-sm font-medium"
data-day="4">Kamis</button>
<button onclick="switchDayTab(5)" class="day-tab px-3 py-2 rounded text-sm font-medium"
data-day="5">Jumat</button>
<button onclick="switchDayTab(6)" class="day-tab px-3 py-2 rounded text-sm font-medium"
data-day="6">Sabtu</button>
</div>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden"> <div class="bg-white rounded-xl shadow overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
@@ -610,7 +659,7 @@
</table> </table>
</div> </div>
<div id="emptyJadwal" class="hidden p-8 text-center text-slate-400"> <div id="emptyJadwal" class="hidden p-8 text-center text-slate-400">
Belum ada jadwal. Belum ada jadwal untuk hari ini.
</div> </div>
</div> </div>
</div> </div>
@@ -629,14 +678,33 @@
</div> </div>
<div class="bg-white p-6 rounded-xl shadow"> <div class="bg-white p-6 rounded-xl shadow">
<h3 class="font-semibold text-lg mb-4">📅 Hari Libur</h3> <h3 class="font-semibold text-lg mb-4">📅 Mode Jadwal</h3>
<div class="flex items-center gap-3 bg-slate-50 p-3 rounded border"> <div class="mb-4">
<label class="block text-sm font-medium mb-2 text-slate-700">Pilih Mode:</label>
<select id="scheduleMode" class="w-full border rounded p-2" onchange="onScheduleModeChange()">
<option value="0">1 Hari (Sama Setiap Hari)</option>
<option value="1">7 Hari (Berbeda Tiap Hari)</option>
</select>
</div>
<div id="skipSundayContainer" class="flex items-center gap-3 bg-slate-50 p-3 rounded border mb-3">
<input type="checkbox" id="skipSunday" style="width: 1.25rem; height: 1.25rem;"> <input type="checkbox" id="skipSunday" style="width: 1.25rem; height: 1.25rem;">
<label for="skipSunday" class="font-medium cursor-pointer select-none text-slate-700">Libur hari Minggu <label for="skipSunday" class="font-medium cursor-pointer select-none text-slate-700">Libur hari Minggu
(Bel mati)</label> (Bel mati)</label>
</div> </div>
<div class="mt-3 text-right"> <p class="text-xs text-slate-500 mb-3" id="scheduleModeHint">Mode 1 Hari: Jadwal sama setiap hari. Centang
<button onclick="saveSkipSunday()" class="btn btn-blue">Simpan</button> opsi di atas untuk menonaktifkan bel di hari Minggu.</p>
<div class="text-right">
<button onclick="saveScheduleMode()" class="btn btn-blue">Simpan Mode</button>
</div>
</div>
<div class="bg-white p-6 rounded-xl shadow">
<h3 class="font-semibold text-lg mb-4">🔔 Track Bel Manual</h3>
<p class="text-sm text-slate-500 mb-3">Track MP3 yang diputar saat tombol TEST ditekan lama (2 detik)</p>
<div class="flex gap-2">
<input id="testTrack" type="number" min="1" max="9999" placeholder="1000" style="width: 100px;">
<button onclick="saveTestTrack()" class="btn btn-blue">Simpan</button>
<button onclick="previewTestTrack()" class="btn btn-green">▶ Test</button>
</div> </div>
</div> </div>
@@ -746,6 +814,10 @@
// --- State & Auth --- // --- State & Auth ---
let jadwal = []; let jadwal = [];
let isLoggedIn = false; let isLoggedIn = false;
let currentScheduleMode = 0; // 0 = 1-hari, 1 = 7-hari
let currentDayTab = 1; // Default to Senin (1)
const dayNames = ['M', 'S', 'S', 'R', 'K', 'J', 'S']; // Minggu, Senin, Selasa, Rabu, Kamis, Jumat, Sabtu
const dayLabels = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
async function login(event) { async function login(event) {
event.preventDefault(); event.preventDefault();
@@ -792,20 +864,50 @@
function render() { function render() {
const body = document.getElementById('body'); const body = document.getElementById('body');
const empty = document.getElementById('emptyJadwal'); const empty = document.getElementById('emptyJadwal');
const dayTabsContainer = document.getElementById('dayTabsContainer');
body.innerHTML = ''; body.innerHTML = '';
if (!jadwal.length) { // Show/hide day tabs based on mode
if (currentScheduleMode === 1) {
dayTabsContainer.classList.remove('hidden');
} else {
dayTabsContainer.classList.add('hidden');
}
// Filter jadwal based on mode
let filteredJadwal = [];
if (currentScheduleMode === 1) {
// 7-day mode: show ALL schedules, mark which ones are active for this day
jadwal.forEach((j, i) => {
const days = j.days !== undefined ? j.days : 0x7F;
const isActiveToday = (days & (1 << currentDayTab)) !== 0;
filteredJadwal.push({ ...j, originalIndex: i, isActiveToday: isActiveToday });
});
} else {
// 1-day mode: show all with global enabled
filteredJadwal = jadwal.map((j, i) => ({ ...j, originalIndex: i, isActiveToday: j.enabled !== false }));
}
if (!filteredJadwal.length) {
empty.classList.remove('hidden'); empty.classList.remove('hidden');
return; return;
} }
empty.classList.add('hidden'); empty.classList.add('hidden');
jadwal.forEach((j, i) => { filteredJadwal.forEach((j, displayIndex) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-50'; tr.className = 'hover:bg-slate-50';
tr.dataset.originalIndex = j.originalIndex;
// In 7-day mode, checkbox controls per-day activation (days bit)
// In 1-day mode, checkbox controls global enabled field
const checkboxTitle = currentScheduleMode === 1
? `Aktif di ${dayLabels[currentDayTab]}`
: 'Enabled';
tr.innerHTML = ` tr.innerHTML = `
<td class="text-center text-slate-500 col-index">${i + 1}</td> <td class="text-center text-slate-500 col-index">${displayIndex + 1}</td>
<td class="text-center"><input type="checkbox" ${j.enabled !== false ? 'checked' : ''} style="width:1.2rem; height:1.2rem;"></td> <td class="text-center"><input type="checkbox" data-enabled ${j.isActiveToday ? 'checked' : ''} title="${checkboxTitle}" style="width:1.2rem; height:1.2rem;"></td>
<td><input type="number" min="0" max="23" value="${j.jam}"></td> <td><input type="number" min="0" max="23" value="${j.jam}"></td>
<td><input type="number" min="0" max="59" value="${j.menit}"></td> <td><input type="number" min="0" max="59" value="${j.menit}"></td>
<td><input type="number" min="1" value="${j.track || j.trackStart || 1}"></td> <td><input type="number" min="1" value="${j.track || j.trackStart || 1}"></td>
@@ -813,49 +915,91 @@
<td class="text-center"> <td class="text-center">
<div class="flex justify-center gap-1"> <div class="flex justify-center gap-1">
<button onclick="preview(${j.track || j.trackStart || 1})" class="btn btn-green small">▶</button> <button onclick="preview(${j.track || j.trackStart || 1})" class="btn btn-green small">▶</button>
<button onclick="del(${i})" class="btn btn-red small">✕</button> <button onclick="delByIndex(${j.originalIndex})" class="btn btn-red small">✕</button>
</div> </div>
</td>`; </td>`;
body.appendChild(tr); body.appendChild(tr);
}); });
} }
function tambah() { function switchDayTab(day) {
if (!isLoggedIn) return; currentDayTab = day;
if (jadwal.length >= 20) return alert('Maksimal 20 jadwal'); // Update active state
jadwal.push({ jam: 7, menit: 0, track: 1, desc: 'Bel Masuk', enabled: true }); document.querySelectorAll('.day-tab').forEach(btn => {
btn.classList.remove('active');
if (parseInt(btn.dataset.day) === day) {
btn.classList.add('active');
}
});
render(); render();
} }
function del(i) { function tambah() {
if (!isLoggedIn) return;
if (jadwal.length >= 20) return alert('Maksimal 20 jadwal');
// In 7-day mode, set only current day; in 1-day mode, set all days
const days = currentScheduleMode === 1 ? (1 << currentDayTab) : 0x7F;
jadwal.push({ jam: 7, menit: 0, track: 1, desc: 'Bel Masuk', enabled: true, days: days });
render();
}
function delByIndex(originalIndex) {
if (!isLoggedIn) return; if (!isLoggedIn) return;
if (confirm('Hapus jadwal ini?')) { if (confirm('Hapus jadwal ini?')) {
jadwal.splice(i, 1); jadwal.splice(originalIndex, 1);
render(); render();
} }
} }
// Keep old del function for compatibility
function del(i) {
delByIndex(i);
}
async function simpan() { async function simpan() {
if (!isLoggedIn) return; if (!isLoggedIn) return;
const rows = document.querySelectorAll('#body tr'); const rows = document.querySelectorAll('#body tr');
const arr = [];
rows.forEach(r => { // Update jadwal array based on displayed rows
const inputs = r.querySelectorAll('input'); rows.forEach(tr => {
if (inputs.length >= 5) { const originalIndex = parseInt(tr.dataset.originalIndex);
arr.push({ if (isNaN(originalIndex) || originalIndex >= jadwal.length) return;
jam: parseInt(inputs[1].value || 0),
menit: parseInt(inputs[2].value || 0), const enabledCb = tr.querySelector('input[data-enabled]');
track: parseInt(inputs[3].value || 1), const numInputs = tr.querySelectorAll('input[type="number"]');
desc: inputs[4].value || '', const textInput = tr.querySelector('input[type="text"]');
enabled: inputs[0].checked
}); if (numInputs.length >= 3 && enabledCb) {
jadwal[originalIndex].jam = parseInt(numInputs[0].value || 0);
jadwal[originalIndex].menit = parseInt(numInputs[1].value || 0);
jadwal[originalIndex].track = parseInt(numInputs[2].value || 1);
jadwal[originalIndex].desc = textInput ? textInput.value : '';
if (currentScheduleMode === 1) {
// 7-day mode: checkbox controls per-day bit in days field
let currentDays = jadwal[originalIndex].days !== undefined ? jadwal[originalIndex].days : 0x7F;
if (enabledCb.checked) {
// Add current day bit
currentDays |= (1 << currentDayTab);
} else {
// Remove current day bit
currentDays &= ~(1 << currentDayTab);
}
jadwal[originalIndex].days = currentDays;
// Keep enabled always true in 7-day mode (days field controls per-day)
jadwal[originalIndex].enabled = true;
} else {
// 1-day mode: checkbox controls global enabled
jadwal[originalIndex].enabled = enabledCb.checked;
}
} }
}); });
await fetch('/api/save', { await fetch('/api/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arr) body: JSON.stringify(jadwal)
}); });
alert('✅ Jadwal Tersimpan!'); alert('✅ Jadwal Tersimpan!');
loadJadwal(); loadJadwal();
@@ -872,7 +1016,49 @@
document.getElementById('mobileTitle').textContent = d.schoolName; document.getElementById('mobileTitle').textContent = d.schoolName;
} }
document.getElementById('skipSunday').checked = d.skipSunday || false; document.getElementById('skipSunday').checked = d.skipSunday || false;
document.getElementById('testTrack').value = d.testTrack || 1000;
if (d.user) document.getElementById('userInput').placeholder = d.user; if (d.user) document.getElementById('userInput').placeholder = d.user;
// Load schedule mode
currentScheduleMode = d.scheduleMode || 0;
document.getElementById('scheduleMode').value = currentScheduleMode;
updateScheduleModeUI();
render(); // Re-render to show/hide days column
}
function updateScheduleModeUI() {
const skipSundayContainer = document.getElementById('skipSundayContainer');
const hint = document.getElementById('scheduleModeHint');
if (currentScheduleMode === 1) {
// Mode 7-hari: hide skipSunday option
skipSundayContainer.classList.add('hidden');
hint.textContent = 'Mode 7 Hari: Pilih hari aktif untuk setiap jadwal di tabel Jadwal.';
} else {
// Mode 1-hari: show skipSunday option
skipSundayContainer.classList.remove('hidden');
hint.textContent = 'Mode 1 Hari: Jadwal sama setiap hari. Centang opsi di atas untuk menonaktifkan bel di hari Minggu.';
}
}
function onScheduleModeChange() {
currentScheduleMode = parseInt(document.getElementById('scheduleMode').value);
updateScheduleModeUI();
render(); // Re-render to show/hide days column
}
async function saveScheduleMode() {
const mode = parseInt(document.getElementById('scheduleMode').value);
const skip = document.getElementById('skipSunday').checked;
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scheduleMode: mode, skipSunday: skip })
});
currentScheduleMode = mode;
alert('Mode Jadwal Disimpan!');
} }
async function saveName() { async function saveName() {
@@ -897,6 +1083,21 @@
alert('Pengaturan Disimpan'); alert('Pengaturan Disimpan');
} }
async function saveTestTrack() {
const track = parseInt(document.getElementById('testTrack').value) || 1000;
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ testTrack: track })
});
alert('Track Test Disimpan: ' + track);
}
async function previewTestTrack() {
const track = parseInt(document.getElementById('testTrack').value) || 1000;
await fetch(`/api/play?track=${track}`);
}
async function syncTime() { async function syncTime() {
const now = new Date(); const now = new Date();
const epoch = Math.floor((now.getTime() - now.getTimezoneOffset() * 60000) / 1000); const epoch = Math.floor((now.getTime() - now.getTimezoneOffset() * 60000) / 1000);

View File

@@ -54,8 +54,12 @@ String adminUser = "admin";
String adminPass = "sekolah123"; String adminPass = "sekolah123";
String apName; String apName;
bool skipSunday = false; bool skipSunday = false;
uint16_t testTrack = 1000; // Default test track for manual bell
uint8_t scheduleMode = 0; // 0 = 1-hari (seragam), 1 = 7-hari (per hari)
struct Sch { uint8_t jam; uint8_t menit; uint16_t track; String desc; bool enabled; uint32_t lastExecuted; }; // days: bit-field untuk hari aktif (bit 0=Minggu, 1=Senin, 2=Selasa, 3=Rabu, 4=Kamis, 5=Jumat, 6=Sabtu)
// Default 0x7F = semua hari aktif (0111 1111)
struct Sch { uint8_t jam; uint8_t menit; uint16_t track; String desc; bool enabled; uint8_t days; uint32_t lastExecuted; };
Sch schedules[MAX_SCHEDULES]; Sch schedules[MAX_SCHEDULES];
uint8_t scheduleCount = 0; uint8_t scheduleCount = 0;
@@ -147,6 +151,8 @@ void loadConfig() {
d["user"] = adminUser; d["user"] = adminUser;
d["pass"] = adminPass; d["pass"] = adminPass;
d["skipSunday"] = skipSunday; d["skipSunday"] = skipSunday;
d["testTrack"] = testTrack;
d["scheduleMode"] = scheduleMode;
File f = LittleFS.open("/config.json", "w"); File f = LittleFS.open("/config.json", "w");
if (f) { serializeJson(d, f); f.close(); } if (f) { serializeJson(d, f); f.close(); }
return; return;
@@ -161,6 +167,8 @@ void loadConfig() {
if (d.containsKey("user")) adminUser = d["user"].as<String>(); if (d.containsKey("user")) adminUser = d["user"].as<String>();
if (d.containsKey("pass")) adminPass = d["pass"].as<String>(); if (d.containsKey("pass")) adminPass = d["pass"].as<String>();
if (d.containsKey("skipSunday")) skipSunday = d["skipSunday"]; if (d.containsKey("skipSunday")) skipSunday = d["skipSunday"];
if (d.containsKey("testTrack")) testTrack = d["testTrack"] | 1000;
if (d.containsKey("scheduleMode")) scheduleMode = d["scheduleMode"] | 0;
} }
} }
@@ -170,6 +178,8 @@ void saveConfig() {
d["user"] = adminUser; d["user"] = adminUser;
d["pass"] = adminPass; d["pass"] = adminPass;
d["skipSunday"] = skipSunday; d["skipSunday"] = skipSunday;
d["testTrack"] = testTrack;
d["scheduleMode"] = scheduleMode;
File f = LittleFS.open("/config.json", "w"); File f = LittleFS.open("/config.json", "w");
if (f) { serializeJson(d, f); f.close(); } if (f) { serializeJson(d, f); f.close(); }
} }
@@ -197,6 +207,7 @@ void loadSchedules() {
schedules[i].track = arr[i]["track"] | 1; schedules[i].track = arr[i]["track"] | 1;
schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>(); schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>();
schedules[i].enabled = arr[i]["enabled"] | true; schedules[i].enabled = arr[i]["enabled"] | true;
schedules[i].days = arr[i]["days"] | 0x7F; // default semua hari aktif
} }
} }
@@ -210,6 +221,7 @@ void saveSchedules() {
o["track"] = schedules[i].track; o["track"] = schedules[i].track;
o["desc"] = schedules[i].desc; o["desc"] = schedules[i].desc;
o["enabled"] = schedules[i].enabled; o["enabled"] = schedules[i].enabled;
o["days"] = schedules[i].days;
} }
File f = LittleFS.open("/schedule.json", "w"); File f = LittleFS.open("/schedule.json", "w");
if (f) { serializeJson(doc, f); f.close(); } if (f) { serializeJson(doc, f); f.close(); }
@@ -319,6 +331,7 @@ void initWebServer() {
o["track"] = schedules[i].track; o["track"] = schedules[i].track;
o["desc"] = schedules[i].desc; o["desc"] = schedules[i].desc;
o["enabled"] = schedules[i].enabled; o["enabled"] = schedules[i].enabled;
o["days"] = schedules[i].days;
} }
String out; serializeJson(d, out); String out; serializeJson(d, out);
req->send(200, "application/json", out); req->send(200, "application/json", out);
@@ -339,6 +352,7 @@ void initWebServer() {
schedules[i].track = arr[i]["track"] | 1; schedules[i].track = arr[i]["track"] | 1;
schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>(); schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>();
schedules[i].enabled = arr[i]["enabled"] | true; schedules[i].enabled = arr[i]["enabled"] | true;
schedules[i].days = arr[i]["days"] | 0x7F;
} }
saveSchedules(); saveSchedules();
req->send(200, "application/json", "{\"ok\":true}"); req->send(200, "application/json", "{\"ok\":true}");
@@ -351,6 +365,8 @@ void initWebServer() {
d["schoolName"] = schoolName; d["schoolName"] = schoolName;
d["user"] = adminUser; d["user"] = adminUser;
d["skipSunday"] = skipSunday; d["skipSunday"] = skipSunday;
d["testTrack"] = testTrack;
d["scheduleMode"] = scheduleMode;
String out; serializeJson(d, out); String out; serializeJson(d, out);
req->send(200, "application/json", out); req->send(200, "application/json", out);
}); });
@@ -362,6 +378,8 @@ void initWebServer() {
if (deserializeJson(d, data, len)) { req->send(400); return; } if (deserializeJson(d, data, len)) { req->send(400); return; }
if (d.containsKey("schoolName")) schoolName = d["schoolName"].as<String>(); if (d.containsKey("schoolName")) schoolName = d["schoolName"].as<String>();
if (d.containsKey("skipSunday")) skipSunday = d["skipSunday"]; if (d.containsKey("skipSunday")) skipSunday = d["skipSunday"];
if (d.containsKey("testTrack")) testTrack = d["testTrack"] | 1000;
if (d.containsKey("scheduleMode")) scheduleMode = d["scheduleMode"] | 0;
saveConfig(); saveConfig();
req->send(200, "application/json", "{\"ok\":true}"); req->send(200, "application/json", "{\"ok\":true}");
broadcastStatus(); broadcastStatus();
@@ -423,6 +441,7 @@ void initWebServer() {
doc["user"] = adminUser; doc["user"] = adminUser;
doc["pass"] = adminPass; doc["pass"] = adminPass;
doc["skipSunday"] = skipSunday; doc["skipSunday"] = skipSunday;
doc["scheduleMode"] = scheduleMode;
JsonArray arr = doc.createNestedArray("schedules"); JsonArray arr = doc.createNestedArray("schedules");
for (uint8_t i = 0; i < scheduleCount; ++i) { for (uint8_t i = 0; i < scheduleCount; ++i) {
JsonObject o = arr.createNestedObject(); JsonObject o = arr.createNestedObject();
@@ -431,6 +450,7 @@ void initWebServer() {
o["track"] = schedules[i].track; o["track"] = schedules[i].track;
o["desc"] = schedules[i].desc; o["desc"] = schedules[i].desc;
o["enabled"] = schedules[i].enabled; o["enabled"] = schedules[i].enabled;
o["days"] = schedules[i].days;
} }
String out; String out;
serializeJson(doc, out); serializeJson(doc, out);
@@ -473,6 +493,7 @@ void initWebServer() {
if (doc.containsKey("schoolName")) schoolName = doc["schoolName"].as<String>(); if (doc.containsKey("schoolName")) schoolName = doc["schoolName"].as<String>();
if (doc.containsKey("user")) adminUser = doc["user"].as<String>(); if (doc.containsKey("user")) adminUser = doc["user"].as<String>();
if (doc.containsKey("pass")) adminPass = doc["pass"].as<String>(); if (doc.containsKey("pass")) adminPass = doc["pass"].as<String>();
if (doc.containsKey("scheduleMode")) scheduleMode = doc["scheduleMode"] | 0;
saveConfig(); saveConfig();
// Update schedules // Update schedules
if (doc.containsKey("schedules")) { if (doc.containsKey("schedules")) {
@@ -485,6 +506,7 @@ void initWebServer() {
schedules[i].track = arr[i]["track"] | 1; schedules[i].track = arr[i]["track"] | 1;
schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>(); schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>();
schedules[i].enabled = arr[i]["enabled"] | true; schedules[i].enabled = arr[i]["enabled"] | true;
schedules[i].days = arr[i]["days"] | 0x7F;
} }
saveSchedules(); saveSchedules();
} }
@@ -533,6 +555,7 @@ void initWebServer() {
doc["user"] = adminUser; doc["user"] = adminUser;
doc["pass"] = adminPass; doc["pass"] = adminPass;
doc["skipSunday"] = skipSunday; doc["skipSunday"] = skipSunday;
doc["scheduleMode"] = scheduleMode;
JsonArray arr = doc.createNestedArray("schedules"); JsonArray arr = doc.createNestedArray("schedules");
for (uint8_t i = 0; i < scheduleCount; ++i) { for (uint8_t i = 0; i < scheduleCount; ++i) {
JsonObject o = arr.createNestedObject(); JsonObject o = arr.createNestedObject();
@@ -541,6 +564,7 @@ void initWebServer() {
o["track"] = schedules[i].track; o["track"] = schedules[i].track;
o["desc"] = schedules[i].desc; o["desc"] = schedules[i].desc;
o["enabled"] = schedules[i].enabled; o["enabled"] = schedules[i].enabled;
o["days"] = schedules[i].days;
} }
File f = LittleFS.open(filename, "w"); File f = LittleFS.open(filename, "w");
if (f) { if (f) {
@@ -587,6 +611,7 @@ void initWebServer() {
if (doc.containsKey("user")) adminUser = doc["user"].as<String>(); if (doc.containsKey("user")) adminUser = doc["user"].as<String>();
if (doc.containsKey("pass")) adminPass = doc["pass"].as<String>(); if (doc.containsKey("pass")) adminPass = doc["pass"].as<String>();
if (doc.containsKey("skipSunday")) skipSunday = doc["skipSunday"]; if (doc.containsKey("skipSunday")) skipSunday = doc["skipSunday"];
if (doc.containsKey("scheduleMode")) scheduleMode = doc["scheduleMode"] | 0;
saveConfig(); saveConfig();
if (doc.containsKey("schedules")) { if (doc.containsKey("schedules")) {
@@ -599,6 +624,7 @@ void initWebServer() {
schedules[i].track = arr[i]["track"] | 1; schedules[i].track = arr[i]["track"] | 1;
schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>(); schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as<String>();
schedules[i].enabled = arr[i]["enabled"] | true; schedules[i].enabled = arr[i]["enabled"] | true;
schedules[i].days = arr[i]["days"] | 0x7F;
} }
saveSchedules(); saveSchedules();
} }
@@ -798,8 +824,8 @@ void loop(){
// Process TEST_BUTTON long press: play test MP3 // Process TEST_BUTTON long press: play test MP3
if (testBtnLongPressPending) { if (testBtnLongPressPending) {
testBtnLongPressPending = false; testBtnLongPressPending = false;
Serial.println("[BUTTON] Long press detected: playing track 1000"); Serial.printf("[BUTTON] Long press detected: playing test track %d\n", testTrack);
playTrack(1000, "Manual Test"); playTrack(testTrack, "Manual Test");
} }
// Process RESET_BUTTON long press: reset admin credentials // Process RESET_BUTTON long press: reset admin credentials
@@ -851,9 +877,18 @@ void loop(){
// schedule loop: for each schedule, compute today's target unix // schedule loop: for each schedule, compute today's target unix
if (now.year() >= 2025) { // only run schedules if date >= 2025 if (now.year() >= 2025) { // only run schedules if date >= 2025
uint8_t today = now.dayOfTheWeek(); // 0=Minggu, 1=Senin, ..., 6=Sabtu
for (uint8_t i=0;i<scheduleCount;i++){ for (uint8_t i=0;i<scheduleCount;i++){
if (!schedules[i].enabled) continue; // skip disabled schedules if (!schedules[i].enabled) continue; // skip disabled schedules
if (skipSunday && now.dayOfTheWeek() == 0) continue; // skip on Sunday if enabled
// Check day based on schedule mode
if (scheduleMode == 1) {
// Mode 7-hari: cek apakah hari ini aktif untuk jadwal ini
if (!(schedules[i].days & (1 << today))) continue;
} else {
// Mode 1-hari (seragam): skip on Sunday if skipSunday enabled
if (skipSunday && today == 0) continue;
}
DateTime target(now.year(), now.month(), now.day(), schedules[i].jam, schedules[i].menit, 0); DateTime target(now.year(), now.month(), now.day(), schedules[i].jam, schedules[i].menit, 0);
uint32_t tUnix = target.unixtime(); uint32_t tUnix = target.unixtime();