Initial commit
|
|
@ -0,0 +1,306 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Smart Pot - Sidebar Demo</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
/* Demo specific styles */
|
||||||
|
.demo-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card h2 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list i {
|
||||||
|
color: #059669;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info strong {
|
||||||
|
color: #1e40af;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info p {
|
||||||
|
color: #1e3a8a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="assets/js/sidebar.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Sidebar Overlay for Mobile -->
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
|
<!-- Dashboard Page -->
|
||||||
|
<div class="dashboard-page">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<!-- Sidebar Toggle Button -->
|
||||||
|
<button class="sidebar-toggle" onclick="toggleSidebarCollapse()">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sidebar Brand -->
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<img src="assets/img/logo.png" alt="Smart Pot Logo" class="sidebar-logo">
|
||||||
|
<div class="sidebar-title">SMART POT</div>
|
||||||
|
<div class="sidebar-subtitle">IoT Monitoring</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Menu -->
|
||||||
|
<nav class="sidebar-menu">
|
||||||
|
<a href="dashboard/" class="menu-item active">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="controller/" class="menu-item">
|
||||||
|
<i class="fas fa-sliders-h"></i>
|
||||||
|
<span>Controller</span>
|
||||||
|
</a>
|
||||||
|
<a href="histori/" class="menu-item">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
<span>Histori</span>
|
||||||
|
</a>
|
||||||
|
<a href="kelola-user/" class="menu-item">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<span>Kelola User</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Sidebar Footer -->
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">Administrator</div>
|
||||||
|
<div class="user-email">admin@smartpot.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="signOutBtn" class="btn-logout">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Wrapper -->
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<!-- Top Header -->
|
||||||
|
<header class="top-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<button class="mobile-menu-btn" onclick="toggleSidebar()" style="display: none;">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
<h1>Sidebar Layout Demo</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="header-time" id="currentTime"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- Welcome Alert -->
|
||||||
|
<div class="alert-info">
|
||||||
|
<strong>🎉 Selamat! Dashboard Smart_Pot Telah Berhasil Di-Update!</strong>
|
||||||
|
<p>Layout sidebar modern telah berhasil diterapkan. Coba klik tombol toggle di sidebar untuk collapse/expand, atau resize browser untuk melihat mode responsive.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Demo Cards -->
|
||||||
|
<div class="demo-card">
|
||||||
|
<h2><i class="fas fa-magic"></i> Fitur Utama Sidebar</h2>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><strong>Fixed Sidebar</strong> - Sidebar tetap di kiri dan tidak scroll bersama konten</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><strong>Collapsible</strong> - Klik tombol toggle untuk minimize/maximize sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><strong>Active Highlighting</strong> - Menu yang aktif memiliki highlight warna dan indicator bar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><strong>Responsive</strong> - Di mobile, sidebar jadi slide-in menu dengan overlay</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><strong>Smooth Animation</strong> - Semua transisi menggunakan smooth CSS animations</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><strong>User Info</strong> - Info user dan tombol logout di bagian bawah sidebar</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-card">
|
||||||
|
<h2><i class="fas fa-palette"></i> Design Modern</h2>
|
||||||
|
<p>
|
||||||
|
Dashboard menggunakan design modern dengan:
|
||||||
|
</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-paint-brush"></i>
|
||||||
|
<span>Gradient hijau untuk sidebar sesuai branding Smart_Pot</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
<span>Logo circular dengan background putih di bagian atas sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-square-full"></i>
|
||||||
|
<span>Border radius dan shadow untuk efek depth</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-icons"></i>
|
||||||
|
<span>Font Awesome icons untuk visual yang jelas</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-mobile-alt"></i>
|
||||||
|
<span>Responsive layout untuk semua ukuran device</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-card">
|
||||||
|
<h2><i class="fas fa-keyboard"></i> Cara Menggunakan</h2>
|
||||||
|
<p><strong>Desktop Mode:</strong></p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-arrow-pointer"></i>
|
||||||
|
<span>Klik menu di sidebar untuk navigasi antar halaman</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-compress"></i>
|
||||||
|
<span>Klik tombol chevron (◄/►) untuk collapse/expand sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-mouse-pointer"></i>
|
||||||
|
<span>Hover menu untuk melihat efek highlight</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;"><strong>Mobile Mode:</strong></p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
<span>Tap tombol hamburger (☰) di header untuk buka sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
<span>Tap di luar sidebar (overlay) untuk tutup</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-card">
|
||||||
|
<h2><i class="fas fa-folder-open"></i> File yang Dimodifikasi</h2>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span><strong>assets/css/style.css</strong> - Styling sidebar dan layout baru</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span><strong>assets/js/sidebar.js</strong> - JavaScript untuk sidebar functionality</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span><strong>dashboard/index.html</strong> - Halaman monitoring dengan sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span><strong>controller/index.html</strong> - Halaman controller dengan sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span><strong>histori/index.html</strong> - Halaman histori dengan sidebar</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-file-code"></i>
|
||||||
|
<span><strong>kelola-user/index.html</strong> - Halaman kelola user dengan sidebar</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-card">
|
||||||
|
<h2><i class="fas fa-info-circle"></i> Dokumentasi Lengkap</h2>
|
||||||
|
<p>
|
||||||
|
Untuk dokumentasi lengkap mengenai sidebar layout, silakan baca file:
|
||||||
|
</p>
|
||||||
|
<p style="background: #f3f4f6; padding: 15px; border-radius: 8px; font-family: monospace; margin-top: 10px;">
|
||||||
|
📄 <strong>SIDEBAR_LAYOUT_GUIDE.md</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 15px;">
|
||||||
|
File tersebut berisi penjelasan detail tentang:
|
||||||
|
</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li><i class="fas fa-check"></i> <span>Fitur-fitur sidebar</span></li>
|
||||||
|
<li><i class="fas fa-check"></i> <span>Design elements dan color scheme</span></li>
|
||||||
|
<li><i class="fas fa-check"></i> <span>Cara customization</span></li>
|
||||||
|
<li><i class="fas fa-check"></i> <span>Responsive breakpoints</span></li>
|
||||||
|
<li><i class="fas fa-check"></i> <span>Tips dan best practices</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-analytics.js";
|
||||||
|
import { getAuth, signOut, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('dashboardPage').hidden = false;
|
||||||
|
document.getElementById('loginMessage').hidden = true;
|
||||||
|
document.getElementById('userEmail').textContent = user.email;
|
||||||
|
document.getElementById('welcomeMessage').textContent = `Welcome back, ${user.email}!`;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
} else {
|
||||||
|
document.getElementById('dashboardPage').hidden = true;
|
||||||
|
document.getElementById('loginMessage').hidden = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign out function
|
||||||
|
document.getElementById('signOutBtn').addEventListener('click', () => {
|
||||||
|
signOut(auth).then(() => {
|
||||||
|
console.log('User signed out');
|
||||||
|
window.location.href = '/';
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 984 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 8.4 MiB |
|
After Width: | Height: | Size: 8.6 MiB |
|
|
@ -0,0 +1,720 @@
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-analytics.js";
|
||||||
|
import { getAuth, signOut, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
import { getDatabase, ref, set, get, onValue, update } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-database.js";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
databaseURL: "https://project-ta-951b4-default-rtdb.firebaseio.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const database = getDatabase(app);
|
||||||
|
const aktuatorRef = ref(database, 'aktuator');
|
||||||
|
|
||||||
|
// Sensor readings RTDB node (auto-detect)
|
||||||
|
// If you already know the exact RTDB path for sensor readings, set it here (example: 'monitoring' or 'monitoring/sensor').
|
||||||
|
const SENSOR_PATH_OVERRIDE = '';
|
||||||
|
|
||||||
|
const SENSOR_PATH_CANDIDATES = [
|
||||||
|
'sensor',
|
||||||
|
'sensors',
|
||||||
|
'monitoring',
|
||||||
|
'monitor',
|
||||||
|
'data_sensor',
|
||||||
|
'sensor_data',
|
||||||
|
// common alternatives
|
||||||
|
'realtime',
|
||||||
|
'data',
|
||||||
|
'nilai_sensor',
|
||||||
|
'smartpot'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTUATOR_KEYS = {
|
||||||
|
water: 'mosvet_1',
|
||||||
|
fertilizer: 'mosvet_2',
|
||||||
|
mixer: 'mosvet_8'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pot states (all ON by default)
|
||||||
|
const potStates = {
|
||||||
|
1: true,
|
||||||
|
2: true,
|
||||||
|
3: true,
|
||||||
|
4: true,
|
||||||
|
5: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Water/Fertilizer states
|
||||||
|
const actuatorStates = {
|
||||||
|
water: true,
|
||||||
|
fertilizer: true,
|
||||||
|
mixer: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize toggle values from RTDB (supports boolean, number, and string forms)
|
||||||
|
function normalizeToggleValue(value, fallback = false) {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value !== 0;
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (['1', 'true', 'on', 'aktif', 'yes', 'y'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'off', 'nonaktif', 'no', 'n', ''].includes(normalized)) return false;
|
||||||
|
|
||||||
|
const numeric = Number(normalized);
|
||||||
|
if (!Number.isNaN(numeric)) return numeric !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all pots are OFF
|
||||||
|
function areAllPotsOff() {
|
||||||
|
return !potStates[1] && !potStates[2] && !potStates[3] && !potStates[4] && !potStates[5];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification/alert to user
|
||||||
|
function showNotification(message) {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToggleUI(statusEl, indicatorEl, isOn) {
|
||||||
|
if (!statusEl || !indicatorEl) return;
|
||||||
|
|
||||||
|
statusEl.textContent = isOn ? 'ON' : 'OFF';
|
||||||
|
indicatorEl.classList.toggle('active', isOn);
|
||||||
|
indicatorEl.classList.toggle('inactive', !isOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle pot function
|
||||||
|
window.togglePot = function(potNumber) {
|
||||||
|
potStates[potNumber] = !potStates[potNumber];
|
||||||
|
const statusEl = document.getElementById(`status${potNumber}`);
|
||||||
|
const indicatorEl = document.getElementById(`indicator${potNumber}`);
|
||||||
|
renderToggleUI(statusEl, indicatorEl, potStates[potNumber]);
|
||||||
|
|
||||||
|
// Update Firebase - Pot 1-5 maps to mosvet_3-7
|
||||||
|
const mosvetNumber = potNumber + 2;
|
||||||
|
const mosvetKey = `mosvet_${mosvetNumber}`;
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[mosvetKey]: potStates[potNumber]
|
||||||
|
}).then(() => {
|
||||||
|
console.log(`POT ${potNumber} updated to ${potStates[potNumber]} (${mosvetKey})`);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle water actuator (mosvet_1 - Pompa air)
|
||||||
|
window.toggleWater = function() {
|
||||||
|
const newWaterState = !actuatorStates.water;
|
||||||
|
|
||||||
|
// Check if all pots are OFF and user is trying to turn water ON
|
||||||
|
if (newWaterState && areAllPotsOff()) {
|
||||||
|
showNotification('Semua pot dalam status OFF. Aktifkan minimal satu pot sebelum mengaktifkan pompa air.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If turning water ON, turn fertilizer OFF (but mixer can stay ON)
|
||||||
|
if (newWaterState) {
|
||||||
|
actuatorStates.water = true;
|
||||||
|
actuatorStates.fertilizer = false;
|
||||||
|
// Keep mixer state as is
|
||||||
|
|
||||||
|
// Update water UI
|
||||||
|
const waterStatusEl = document.getElementById('waterStatus');
|
||||||
|
const waterIndicatorEl = document.getElementById('waterIndicator');
|
||||||
|
waterStatusEl.textContent = 'ON';
|
||||||
|
waterIndicatorEl.classList.add('active');
|
||||||
|
waterIndicatorEl.classList.remove('inactive');
|
||||||
|
|
||||||
|
// Update fertilizer UI to OFF
|
||||||
|
const fertilizerStatusEl = document.getElementById('fertilizerStatus');
|
||||||
|
const fertilizerIndicatorEl = document.getElementById('fertilizerIndicator');
|
||||||
|
fertilizerStatusEl.textContent = 'OFF';
|
||||||
|
fertilizerIndicatorEl.classList.add('inactive');
|
||||||
|
fertilizerIndicatorEl.classList.remove('active');
|
||||||
|
|
||||||
|
// Update Firebase
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[ACTUATOR_KEYS.water]: true,
|
||||||
|
[ACTUATOR_KEYS.fertilizer]: false
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Water ON, Fertilizer OFF');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Turning water OFF
|
||||||
|
actuatorStates.water = false;
|
||||||
|
|
||||||
|
const waterStatusEl = document.getElementById('waterStatus');
|
||||||
|
const waterIndicatorEl = document.getElementById('waterIndicator');
|
||||||
|
waterStatusEl.textContent = 'OFF';
|
||||||
|
waterIndicatorEl.classList.add('inactive');
|
||||||
|
waterIndicatorEl.classList.remove('active');
|
||||||
|
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[ACTUATOR_KEYS.water]: false
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Water OFF');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle fertilizer actuator (mosvet_2 - Pupuk)
|
||||||
|
window.toggleFertilizer = function() {
|
||||||
|
const newFertilizerState = !actuatorStates.fertilizer;
|
||||||
|
|
||||||
|
// Check if all pots are OFF and user is trying to turn fertilizer ON
|
||||||
|
if (newFertilizerState && areAllPotsOff()) {
|
||||||
|
showNotification('Semua pot dalam status OFF. Aktifkan minimal satu pot sebelum mengaktifkan penyiraman pupuk.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If turning fertilizer ON, turn water OFF (but mixer can stay ON)
|
||||||
|
if (newFertilizerState) {
|
||||||
|
actuatorStates.fertilizer = true;
|
||||||
|
actuatorStates.water = false;
|
||||||
|
// Keep mixer state as is
|
||||||
|
|
||||||
|
// Update fertilizer UI
|
||||||
|
const fertilizerStatusEl = document.getElementById('fertilizerStatus');
|
||||||
|
const fertilizerIndicatorEl = document.getElementById('fertilizerIndicator');
|
||||||
|
fertilizerStatusEl.textContent = 'ON';
|
||||||
|
fertilizerIndicatorEl.classList.add('active');
|
||||||
|
fertilizerIndicatorEl.classList.remove('inactive');
|
||||||
|
|
||||||
|
// Update water UI to OFF
|
||||||
|
const waterStatusEl = document.getElementById('waterStatus');
|
||||||
|
const waterIndicatorEl = document.getElementById('waterIndicator');
|
||||||
|
waterStatusEl.textContent = 'OFF';
|
||||||
|
waterIndicatorEl.classList.add('inactive');
|
||||||
|
waterIndicatorEl.classList.remove('active');
|
||||||
|
|
||||||
|
// Update Firebase
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[ACTUATOR_KEYS.water]: false,
|
||||||
|
[ACTUATOR_KEYS.fertilizer]: true
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Fertilizer ON, Water OFF');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Turning fertilizer OFF
|
||||||
|
actuatorStates.fertilizer = false;
|
||||||
|
|
||||||
|
const fertilizerStatusEl = document.getElementById('fertilizerStatus');
|
||||||
|
const fertilizerIndicatorEl = document.getElementById('fertilizerIndicator');
|
||||||
|
fertilizerStatusEl.textContent = 'OFF';
|
||||||
|
fertilizerIndicatorEl.classList.add('inactive');
|
||||||
|
fertilizerIndicatorEl.classList.remove('active');
|
||||||
|
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[ACTUATOR_KEYS.fertilizer]: false
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Fertilizer OFF');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle mixer actuator (mosvet_8 - Pompa pengaduk)
|
||||||
|
window.toggleMixer = function() {
|
||||||
|
const newMixerState = !actuatorStates.mixer;
|
||||||
|
|
||||||
|
const mixerStatusEl = document.getElementById('mixerStatus');
|
||||||
|
const mixerIndicatorEl = document.getElementById('mixerIndicator');
|
||||||
|
|
||||||
|
if (!mixerStatusEl || !mixerIndicatorEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all pots are OFF and user is trying to turn mixer ON
|
||||||
|
if (newMixerState && areAllPotsOff()) {
|
||||||
|
showNotification('Semua pot dalam status OFF. Aktifkan minimal satu pot sebelum mengaktifkan pengaduk larutan nutrisi.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMixerState) {
|
||||||
|
actuatorStates.mixer = true;
|
||||||
|
// Keep water and fertilizer states as is
|
||||||
|
|
||||||
|
mixerStatusEl.textContent = 'ON';
|
||||||
|
mixerIndicatorEl.classList.add('active');
|
||||||
|
mixerIndicatorEl.classList.remove('inactive');
|
||||||
|
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[ACTUATOR_KEYS.mixer]: true
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Mixer ON');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actuatorStates.mixer = false;
|
||||||
|
mixerStatusEl.textContent = 'OFF';
|
||||||
|
mixerIndicatorEl.classList.add('inactive');
|
||||||
|
mixerIndicatorEl.classList.remove('active');
|
||||||
|
|
||||||
|
update(aktuatorRef, {
|
||||||
|
[ACTUATOR_KEYS.mixer]: false
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Mixer OFF');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating Firebase:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
const dashboardPageEl = document.getElementById('dashboardPage');
|
||||||
|
const loginMessageEl = document.getElementById('loginMessage');
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (dashboardPageEl) dashboardPageEl.hidden = false;
|
||||||
|
if (loginMessageEl) loginMessageEl.hidden = true;
|
||||||
|
|
||||||
|
const userEmailEl = document.getElementById('userEmail');
|
||||||
|
if (userEmailEl) userEmailEl.textContent = user.email;
|
||||||
|
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
// Load sensor readings from Firebase (RTDB)
|
||||||
|
startSensorSubscription();
|
||||||
|
|
||||||
|
// Load actuator states from Firebase
|
||||||
|
loadActuatorStates();
|
||||||
|
} else {
|
||||||
|
if (dashboardPageEl) dashboardPageEl.hidden = true;
|
||||||
|
if (loginMessageEl) loginMessageEl.hidden = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load actuator states from Firebase
|
||||||
|
function loadActuatorStates() {
|
||||||
|
onValue(aktuatorRef, (snapshot) => {
|
||||||
|
if (snapshot.exists()) {
|
||||||
|
const data = snapshot.val();
|
||||||
|
console.log('Actuator data loaded:', data);
|
||||||
|
|
||||||
|
// Update pot states (Pot 1-5 maps to mosvet_3-7)
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const mosvetNumber = i + 2;
|
||||||
|
const mosvetKey = `mosvet_${mosvetNumber}`;
|
||||||
|
if (data[mosvetKey] !== undefined) {
|
||||||
|
potStates[i] = normalizeToggleValue(data[mosvetKey], potStates[i]);
|
||||||
|
const statusEl = document.getElementById(`status${i}`);
|
||||||
|
const indicatorEl = document.getElementById(`indicator${i}`);
|
||||||
|
|
||||||
|
renderToggleUI(statusEl, indicatorEl, potStates[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update water state (mosvet_1)
|
||||||
|
if (data[ACTUATOR_KEYS.water] !== undefined) {
|
||||||
|
actuatorStates.water = normalizeToggleValue(data[ACTUATOR_KEYS.water], actuatorStates.water);
|
||||||
|
const statusEl = document.getElementById('waterStatus');
|
||||||
|
const indicatorEl = document.getElementById('waterIndicator');
|
||||||
|
|
||||||
|
renderToggleUI(statusEl, indicatorEl, actuatorStates.water);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fertilizer state (mosvet_2)
|
||||||
|
if (data[ACTUATOR_KEYS.fertilizer] !== undefined) {
|
||||||
|
actuatorStates.fertilizer = normalizeToggleValue(data[ACTUATOR_KEYS.fertilizer], actuatorStates.fertilizer);
|
||||||
|
const statusEl = document.getElementById('fertilizerStatus');
|
||||||
|
const indicatorEl = document.getElementById('fertilizerIndicator');
|
||||||
|
|
||||||
|
renderToggleUI(statusEl, indicatorEl, actuatorStates.fertilizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mixer state (mosvet_8)
|
||||||
|
if (data[ACTUATOR_KEYS.mixer] !== undefined) {
|
||||||
|
actuatorStates.mixer = normalizeToggleValue(data[ACTUATOR_KEYS.mixer], actuatorStates.mixer);
|
||||||
|
const statusEl = document.getElementById('mixerStatus');
|
||||||
|
const indicatorEl = document.getElementById('mixerIndicator');
|
||||||
|
|
||||||
|
renderToggleUI(statusEl, indicatorEl, actuatorStates.mixer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Initialize default values
|
||||||
|
initializeDefaultActuatorStates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize default actuator states
|
||||||
|
function initializeDefaultActuatorStates() {
|
||||||
|
const defaultData = {
|
||||||
|
mosvet_1: true, // Pompa air
|
||||||
|
mosvet_2: true, // Pupuk
|
||||||
|
mosvet_3: true, // Soil 1
|
||||||
|
mosvet_4: true, // Soil 2
|
||||||
|
mosvet_5: true, // Soil 3
|
||||||
|
mosvet_6: true, // Soil 4
|
||||||
|
mosvet_7: true, // Soil 5
|
||||||
|
mosvet_8: true // Pompa pengaduk
|
||||||
|
};
|
||||||
|
|
||||||
|
set(aktuatorRef, defaultData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Default actuator states initialized');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error initializing actuator states:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNumber(...candidates) {
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaterflowAvailability(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value > 0;
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (['1', 'true', 'on', 'ada', 'available', 'air', 'yes', 'y'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'off', 'habis', 'empty', 'no', 'n', ''].includes(normalized)) return false;
|
||||||
|
|
||||||
|
const numeric = Number(normalized);
|
||||||
|
if (!Number.isNaN(numeric)) return numeric > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWaterflowBadge(isAvailable) {
|
||||||
|
const badge = document.getElementById('waterflowBadge');
|
||||||
|
const dot = document.getElementById('waterflowDot');
|
||||||
|
const text = document.getElementById('waterflowText');
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
badge.classList.toggle('has-water', isAvailable);
|
||||||
|
badge.classList.toggle('no-water', !isAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dot) {
|
||||||
|
dot.classList.toggle('active', isAvailable);
|
||||||
|
dot.classList.toggle('inactive', !isAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
text.textContent = isAvailable ? 'AIR TERSEDIA' : 'AIR HABIS';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSensorUI(data) {
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
|
||||||
|
// Temperature
|
||||||
|
const temperature = pickNumber(
|
||||||
|
data.dhtTemp,
|
||||||
|
data.temperature,
|
||||||
|
data.temp,
|
||||||
|
data.suhu,
|
||||||
|
data.suhu_dht,
|
||||||
|
data.dht
|
||||||
|
);
|
||||||
|
if (temperature !== null) {
|
||||||
|
const el = document.getElementById('dhtTemp');
|
||||||
|
if (el) el.textContent = Math.round(temperature * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Air humidity
|
||||||
|
const humidity = pickNumber(
|
||||||
|
data.dhtHumidity,
|
||||||
|
data.humidity,
|
||||||
|
data.kelembapan,
|
||||||
|
data.kelembapan_udara,
|
||||||
|
data.airHumidity,
|
||||||
|
data.air_humidity,
|
||||||
|
data.humid
|
||||||
|
);
|
||||||
|
if (humidity !== null) {
|
||||||
|
const el = document.getElementById('dhtHumidity');
|
||||||
|
if (el) el.textContent = Math.round(humidity * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waterflowRaw = data.water_flow ?? data.waterflow ?? data.waterFlow;
|
||||||
|
const waterflowAvailable = parseWaterflowAvailability(waterflowRaw);
|
||||||
|
if (waterflowAvailable !== null) {
|
||||||
|
updateWaterflowBadge(waterflowAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light
|
||||||
|
const light = pickNumber(
|
||||||
|
data.ldr,
|
||||||
|
data.sunLight,
|
||||||
|
data.light,
|
||||||
|
data.cahaya,
|
||||||
|
data.ldr_percent,
|
||||||
|
data.lux
|
||||||
|
);
|
||||||
|
if (light !== null) {
|
||||||
|
const el = document.getElementById('sunLight');
|
||||||
|
if (el) el.textContent = Math.round(light * 100) / 100;
|
||||||
|
|
||||||
|
const statusEl = document.querySelector('.sensor-light .status');
|
||||||
|
if (statusEl) {
|
||||||
|
const sunStatus = light < 250 ? 'Gelap' : light < 1000 ? 'Sedang' : light < 2000 ? 'Terang' : 'Sangat Terang';
|
||||||
|
statusEl.textContent = sunStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moisture per pot (supports several common shapes)
|
||||||
|
const moistureRoot =
|
||||||
|
data.moisture ||
|
||||||
|
data.kelembaban ||
|
||||||
|
data.soilMoisture ||
|
||||||
|
data.soil_moisture ||
|
||||||
|
data.kelembaban_tanah ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const moistureArray =
|
||||||
|
(Array.isArray(data.moisture) && data.moisture) ||
|
||||||
|
(Array.isArray(data.kelembaban) && data.kelembaban) ||
|
||||||
|
(Array.isArray(data.soilMoisture) && data.soilMoisture) ||
|
||||||
|
(Array.isArray(data.soil_moisture) && data.soil_moisture) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
let resolvedSoilCount = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const direct = pickNumber(
|
||||||
|
data[`moisture${i}`],
|
||||||
|
data[`moisture_${i}`],
|
||||||
|
data[`moisturePot${i}`],
|
||||||
|
data[`moisture_pot_${i}`],
|
||||||
|
data[`kelembaban${i}`],
|
||||||
|
data[`kelembaban_${i}`],
|
||||||
|
data[`kelembabanTanah${i}`],
|
||||||
|
data[`kelembaban_tanah_${i}`],
|
||||||
|
data[`soil${i}`],
|
||||||
|
data[`soil_${i}`],
|
||||||
|
data[`soilMoisture${i}`],
|
||||||
|
data[`soil_moisture_${i}`],
|
||||||
|
data[`humidity${i}`],
|
||||||
|
data[`humidity_${i}`],
|
||||||
|
data[`adc${i}`],
|
||||||
|
data[`adc_${i}`],
|
||||||
|
data[`sensor${i}`],
|
||||||
|
data[`sensor_${i}`],
|
||||||
|
data[`pot${i}`],
|
||||||
|
data[`pot_${i}`]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromArray = moistureArray ? pickNumber(moistureArray[i - 1], moistureArray[i], moistureArray[String(i)]) : null;
|
||||||
|
|
||||||
|
const nested = moistureRoot
|
||||||
|
? pickNumber(
|
||||||
|
moistureRoot[i],
|
||||||
|
moistureRoot[String(i)],
|
||||||
|
moistureRoot[`pot${i}`],
|
||||||
|
moistureRoot[`pot_${i}`],
|
||||||
|
moistureRoot[`sensor${i}`],
|
||||||
|
moistureRoot[`sensor_${i}`],
|
||||||
|
moistureRoot[`soil${i}`],
|
||||||
|
moistureRoot[`soil_${i}`],
|
||||||
|
moistureRoot[`moisture${i}`],
|
||||||
|
moistureRoot[`moisture_${i}`],
|
||||||
|
moistureRoot[`kelembaban${i}`],
|
||||||
|
moistureRoot[`kelembaban_${i}`]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const directPotNode =
|
||||||
|
data[`pot${i}`] ||
|
||||||
|
data[`pot_${i}`] ||
|
||||||
|
data[`sensor${i}`] ||
|
||||||
|
data[`sensor_${i}`] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const potNode = (data.pots && (data.pots[i] || data.pots[String(i)] || data.pots[`pot${i}`]))
|
||||||
|
|| (data.pot && (data.pot[i] || data.pot[String(i)] || data.pot[`pot${i}`]))
|
||||||
|
|| directPotNode;
|
||||||
|
|
||||||
|
const potMoisture = potNode
|
||||||
|
? pickNumber(
|
||||||
|
potNode,
|
||||||
|
potNode.moisture,
|
||||||
|
potNode.kelembaban,
|
||||||
|
potNode.soil,
|
||||||
|
potNode.soil_moisture,
|
||||||
|
potNode.soilMoisture,
|
||||||
|
potNode.kelembaban_tanah,
|
||||||
|
potNode.humidity,
|
||||||
|
potNode.adc,
|
||||||
|
potNode.value,
|
||||||
|
potNode.nilai
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const value = pickNumber(direct, fromArray, nested, potMoisture);
|
||||||
|
if (value !== null) {
|
||||||
|
const el = document.getElementById(`moisture${i}`);
|
||||||
|
if (el) el.textContent = Math.round(value);
|
||||||
|
resolvedSoilCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSoilCount === 0 && !didLogSoilDebug) {
|
||||||
|
didLogSoilDebug = true;
|
||||||
|
console.warn('[Sensor] Soil values not mapped yet. Available keys:', Object.keys(data || {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFirstExistingPath(paths) {
|
||||||
|
for (const path of paths) {
|
||||||
|
try {
|
||||||
|
const snapshot = await get(ref(database, path));
|
||||||
|
if (snapshot.exists()) return path;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Sensor] Cannot probe path '${path}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSensorObject(obj) {
|
||||||
|
if (!obj || typeof obj !== 'object') return false;
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
return keys.some((k) =>
|
||||||
|
/temp|suhu|dht|cahaya|light|ldr|lux|moisture|kelembaban|soil/i.test(k)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectSensorPathFromRoot() {
|
||||||
|
// Best-effort: read root, then pick a child node that contains sensor-ish fields.
|
||||||
|
try {
|
||||||
|
const rootRef = ref(database, '/');
|
||||||
|
const rootSnap = await get(rootRef);
|
||||||
|
if (!rootSnap.exists()) return null;
|
||||||
|
const rootVal = rootSnap.val();
|
||||||
|
|
||||||
|
if (!rootVal || typeof rootVal !== 'object') return null;
|
||||||
|
const topKeys = Object.keys(rootVal);
|
||||||
|
console.log('[Sensor] RTDB root keys:', topKeys);
|
||||||
|
|
||||||
|
for (const key of topKeys) {
|
||||||
|
const child = rootVal[key];
|
||||||
|
if (looksLikeSensorObject(child)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Sensor] Root probe failed (rules/network):', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopSensorSubscription = null;
|
||||||
|
let didLogFirstSensorPayload = false;
|
||||||
|
let didLogSoilDebug = false;
|
||||||
|
let sensorCandidateIndex = 0;
|
||||||
|
|
||||||
|
async function startSensorSubscription(forcedPath = null, attempt = 0) {
|
||||||
|
try {
|
||||||
|
if (typeof stopSensorSubscription === 'function') {
|
||||||
|
stopSensorSubscription();
|
||||||
|
stopSensorSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overridePath = typeof SENSOR_PATH_OVERRIDE === 'string' ? SENSOR_PATH_OVERRIDE.trim() : '';
|
||||||
|
if (!forcedPath && overridePath) {
|
||||||
|
forcedPath = overridePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) If forcedPath provided, use it directly
|
||||||
|
// 2) Try root-based detection (most accurate)
|
||||||
|
// 3) Otherwise, try candidates (fallback)
|
||||||
|
const detected = forcedPath ? null : await detectSensorPathFromRoot();
|
||||||
|
const sensorPath = forcedPath || detected || (await findFirstExistingPath(SENSOR_PATH_CANDIDATES));
|
||||||
|
console.log(
|
||||||
|
'[Sensor] Subscribing to:',
|
||||||
|
sensorPath,
|
||||||
|
forcedPath ? '(forced)' : (detected ? '(detected)' : '(fallback)')
|
||||||
|
);
|
||||||
|
|
||||||
|
stopSensorSubscription = onValue(
|
||||||
|
ref(database, sensorPath),
|
||||||
|
(snapshot) => {
|
||||||
|
if (!snapshot.exists()) {
|
||||||
|
console.warn('[Sensor] No data at path:', sensorPath);
|
||||||
|
|
||||||
|
// If we got here via fallback and there is no data, try the next candidate.
|
||||||
|
// This helps when the real sensor node name is different.
|
||||||
|
if (!forcedPath && !detected && SENSOR_PATH_CANDIDATES.length > 1) {
|
||||||
|
if (attempt < SENSOR_PATH_CANDIDATES.length - 1) {
|
||||||
|
const nextIndex = (sensorCandidateIndex + 1) % SENSOR_PATH_CANDIDATES.length;
|
||||||
|
sensorCandidateIndex = nextIndex;
|
||||||
|
const nextPath = SENSOR_PATH_CANDIDATES[sensorCandidateIndex];
|
||||||
|
if (nextPath !== sensorPath) {
|
||||||
|
console.log('[Sensor] Trying next path:', nextPath);
|
||||||
|
startSensorSubscription(nextPath, attempt + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[Sensor] All sensor path candidates returned empty. Set the correct RTDB path in app.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = snapshot.val();
|
||||||
|
if (!didLogFirstSensorPayload) {
|
||||||
|
didLogFirstSensorPayload = true;
|
||||||
|
console.log('[Sensor] First payload:', payload);
|
||||||
|
}
|
||||||
|
updateSensorUI(payload);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[Sensor] Subscription error:', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Sensor] Failed to start subscription:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign out function
|
||||||
|
const signOutBtn = document.getElementById('signOutBtn');
|
||||||
|
if (signOutBtn) {
|
||||||
|
signOutBtn.addEventListener('click', () => {
|
||||||
|
signOut(auth).then(() => {
|
||||||
|
console.log('User signed out');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-analytics.js";
|
||||||
|
import { getAuth, signOut, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
import { getDatabase, ref, set, get, onValue } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-database.js";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
databaseURL: "https://project-ta-951b4-default-rtdb.firebaseio.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const database = getDatabase(app);
|
||||||
|
const kontrolRef = ref(database, 'kontrol');
|
||||||
|
|
||||||
|
// Notification function
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
const messageEl = notification.querySelector('.notification-message');
|
||||||
|
|
||||||
|
notification.classList.remove('success', 'error', 'warning', 'hidden');
|
||||||
|
notification.classList.add(type);
|
||||||
|
messageEl.textContent = message;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('dashboardPage').hidden = false;
|
||||||
|
// document.getElementById('loginMessage').hidden = true;
|
||||||
|
document.getElementById('userEmail').textContent = user.email;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
initializeController();
|
||||||
|
} else {
|
||||||
|
document.getElementById('dashboardPage').hidden = true;
|
||||||
|
// document.getElementById('loginMessage').hidden = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Sign out function
|
||||||
|
document.getElementById('signOutBtn').addEventListener('click', () => {
|
||||||
|
signOut(auth).then(() => {
|
||||||
|
console.log('User signed out');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize controller
|
||||||
|
function initializeController() {
|
||||||
|
// Load data from Firebase
|
||||||
|
loadControllerData();
|
||||||
|
|
||||||
|
// Mode Otomatis - Navigate to ModeOtomatis.html
|
||||||
|
const modeOtomatisBtn = document.getElementById('modeOtomatis');
|
||||||
|
if (modeOtomatisBtn) {
|
||||||
|
modeOtomatisBtn.addEventListener('click', function() {
|
||||||
|
window.location.href = './ModeOtomatis.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode Waktu - Show notification for now
|
||||||
|
const modeWaktuBtn = document.getElementById('modeWaktu');
|
||||||
|
if (modeWaktuBtn) {
|
||||||
|
modeWaktuBtn.addEventListener('click', function() {
|
||||||
|
window.location.href = './ModeWaktu.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load controller data from Firebase
|
||||||
|
function loadControllerData() {
|
||||||
|
onValue(kontrolRef, (snapshot) => {
|
||||||
|
if (snapshot.exists()) {
|
||||||
|
const data = snapshot.val();
|
||||||
|
console.log('Controller data loaded:', data);
|
||||||
|
|
||||||
|
// Update UI if needed
|
||||||
|
// You can add code here to display current values
|
||||||
|
} else {
|
||||||
|
// Initialize default values if not exists
|
||||||
|
initializeDefaultControllerData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize default controller data in Firebase
|
||||||
|
function initializeDefaultControllerData() {
|
||||||
|
const defaultData = {
|
||||||
|
batas_atas: 80,
|
||||||
|
batas_bawah: 30,
|
||||||
|
durasi_1: 5,
|
||||||
|
durasi_2: 5,
|
||||||
|
otomatis: false,
|
||||||
|
waktu: false,
|
||||||
|
waktu_1: "",
|
||||||
|
waktu_2: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
set(kontrolRef, defaultData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Default controller data initialized');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error initializing data:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,793 @@
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAuth, onAuthStateChanged, signOut } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
import { getDatabase, ref, onValue } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-database.js";
|
||||||
|
|
||||||
|
// Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
databaseURL: "https://project-ta-951b4-default-rtdb.firebaseio.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const database = getDatabase(app);
|
||||||
|
const historyRef = ref(database, 'history');
|
||||||
|
|
||||||
|
let listenersInitialized = false;
|
||||||
|
let stopHistorySubscription = null;
|
||||||
|
|
||||||
|
// Initialize history state
|
||||||
|
let historyData = [];
|
||||||
|
let filteredData = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let itemsPerPage = 20;
|
||||||
|
|
||||||
|
// Notification function
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
if (!notification) return;
|
||||||
|
|
||||||
|
const messageEl = notification.querySelector('.notification-message');
|
||||||
|
|
||||||
|
notification.classList.remove('success', 'error', 'warning', 'info', 'hidden');
|
||||||
|
notification.classList.add(type);
|
||||||
|
|
||||||
|
if (messageEl) {
|
||||||
|
messageEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElementDisplay(id, displayValue) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.style.display = displayValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElementHidden(id, hiddenValue) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.hidden = hiddenValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pot numbers from pot_aktif data
|
||||||
|
function extractPotsFromData(potAktif) {
|
||||||
|
const pots = [];
|
||||||
|
|
||||||
|
if (!potAktif) {
|
||||||
|
return pots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array format
|
||||||
|
if (Array.isArray(potAktif)) {
|
||||||
|
potAktif.forEach((value) => {
|
||||||
|
const potNum = Number(value);
|
||||||
|
if (Number.isFinite(potNum) && potNum >= 1 && potNum <= 5) {
|
||||||
|
pots.push(potNum);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(new Set(pots));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle object format
|
||||||
|
if (typeof potAktif === 'object') {
|
||||||
|
// Check for numeric keys (like 0, 1, 2, etc.)
|
||||||
|
const numericKeys = Object.keys(potAktif).filter((k) => /^\d+$/.test(k));
|
||||||
|
if (numericKeys.length > 0) {
|
||||||
|
Object.values(potAktif).forEach((value) => {
|
||||||
|
const potNum = Number(value);
|
||||||
|
if (Number.isFinite(potNum) && potNum >= 1 && potNum <= 5) {
|
||||||
|
pots.push(potNum);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(new Set(pots));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pot_1, pot_2 format or pot1, pot2 format
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const potKey = `pot_${i}`;
|
||||||
|
const potKeyAlt = `pot${i}`;
|
||||||
|
if (potAktif[potKey] === true || potAktif[potKeyAlt] === true) {
|
||||||
|
pots.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(pots));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get allowed pots for a given event (Self-Contained & Immutable)
|
||||||
|
function getAllowedPotsForEvent(entry) {
|
||||||
|
// 1. PRIMARY: Extract directly from 'pots' array or object in the history entry
|
||||||
|
if (entry.pots !== undefined && entry.pots !== null) {
|
||||||
|
const extractedPots = extractPotsFromData(entry.pots);
|
||||||
|
if (extractedPots.length > 0) {
|
||||||
|
console.log(`✅ Using self-contained pots data:`, extractedPots);
|
||||||
|
return extractedPots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. FALLBACK FOR VERY OLD HISTORY
|
||||||
|
// If there is no 'pots' data, fallback to inferring from attached sensor data
|
||||||
|
const type = String(entry.type ?? '').toLowerCase().trim();
|
||||||
|
if (type.includes('waktu_jadwal') || type.includes('sensor_threshold')) {
|
||||||
|
const potsWithData = extractPotsWithSensorData(entry);
|
||||||
|
if (potsWithData.length > 0) {
|
||||||
|
console.log(`🔍 Inferred pots dari sensor data (fallback):`, potsWithData);
|
||||||
|
return potsWithData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`❓ Event tidak bisa di-filter (tidak ada info pot)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract pots yang memiliki sensor data di event
|
||||||
|
function extractPotsWithSensorData(entry) {
|
||||||
|
const potsWithData = [];
|
||||||
|
const seenPots = new Set();
|
||||||
|
|
||||||
|
Object.entries(entry).forEach(([key, value]) => {
|
||||||
|
const match = key.match(/^soil[_-]?(\d+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pot = parseInt(match[1], 10);
|
||||||
|
const sensorValue = normalizeNumber(value);
|
||||||
|
|
||||||
|
// Hanya hitung pot yang memiliki sensor data valid
|
||||||
|
if (Number.isFinite(pot) && sensorValue !== null && !seenPots.has(pot)) {
|
||||||
|
seenPots.add(pot);
|
||||||
|
potsWithData.push(pot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return potsWithData.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication check
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
const userEmailEl = document.getElementById('userEmail');
|
||||||
|
if (userEmailEl) {
|
||||||
|
userEmailEl.textContent = user.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
setElementDisplay('dashboardPage', 'block');
|
||||||
|
setElementHidden('loginMessage', true);
|
||||||
|
initializeHistory();
|
||||||
|
} else {
|
||||||
|
setElementDisplay('dashboardPage', 'none');
|
||||||
|
setElementHidden('loginMessage', false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout handler
|
||||||
|
const signOutBtn = document.getElementById('signOutBtn');
|
||||||
|
if (signOutBtn) {
|
||||||
|
signOutBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await signOut(auth);
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error logging out: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeHistory() {
|
||||||
|
const entriesSelect = document.getElementById('entriesPerPage');
|
||||||
|
if (entriesSelect) {
|
||||||
|
itemsPerPage = parseInt(entriesSelect.value, 10) || 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listenersInitialized) {
|
||||||
|
setupPagination();
|
||||||
|
setupFilterHandlers();
|
||||||
|
listenersInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHistoryFromFirebase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistoryFromFirebase() {
|
||||||
|
if (typeof stopHistorySubscription === 'function') {
|
||||||
|
stopHistorySubscription();
|
||||||
|
stopHistorySubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHistorySubscription = onValue(historyRef, (snapshot) => {
|
||||||
|
const rawHistory = snapshot.exists() ? snapshot.val() : {};
|
||||||
|
historyData = transformHistoryToRows(rawHistory);
|
||||||
|
applyFiltersAndSearch(false);
|
||||||
|
|
||||||
|
if (!snapshot.exists()) {
|
||||||
|
showNotification('Belum ada data histori di Firebase', 'warning');
|
||||||
|
}
|
||||||
|
}, (error) => {
|
||||||
|
console.error('Gagal membaca data history:', error);
|
||||||
|
showNotification('Gagal mengambil data histori dari Firebase', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformHistoryToRows(historyObject) {
|
||||||
|
const rows = [];
|
||||||
|
let rowCounter = 0;
|
||||||
|
|
||||||
|
function walk(node, context = {}) {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLogObject(node)) {
|
||||||
|
const parsedRows = parseLogObject(node, context);
|
||||||
|
parsedRows.forEach((row) => {
|
||||||
|
rowCounter += 1;
|
||||||
|
row.id = `row-${rowCounter}`;
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(node).forEach(([key, value]) => {
|
||||||
|
const nextContext = { ...context };
|
||||||
|
|
||||||
|
if (!nextContext.date && isDateKey(key)) {
|
||||||
|
nextContext.date = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextContext.time && isTimeKey(key)) {
|
||||||
|
nextContext.time = normalizeTimeString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(value, nextContext);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(historyObject, {});
|
||||||
|
rows.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLogObject(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return keys.some((key) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
return lowerKey.startsWith('soil_') ||
|
||||||
|
lowerKey.startsWith('soil') ||
|
||||||
|
lowerKey === 'kelembapan' ||
|
||||||
|
lowerKey === 'moisture' ||
|
||||||
|
lowerKey === 'temp' ||
|
||||||
|
lowerKey === 'suhu' ||
|
||||||
|
lowerKey === 'ldr' ||
|
||||||
|
lowerKey === 'light' ||
|
||||||
|
lowerKey === 'timestamp' ||
|
||||||
|
lowerKey === 'water_flow' ||
|
||||||
|
lowerKey === 'waterflow' ||
|
||||||
|
lowerKey === 'type' ||
|
||||||
|
lowerKey === 'event_type' ||
|
||||||
|
lowerKey === 'activity_type' ||
|
||||||
|
lowerKey === 'source' ||
|
||||||
|
lowerKey === 'mode' ||
|
||||||
|
lowerKey === 'activity' ||
|
||||||
|
lowerKey === 'event' ||
|
||||||
|
lowerKey === 'action' ||
|
||||||
|
lowerKey === 'schedule_name' ||
|
||||||
|
lowerKey === 'schedule_id' ||
|
||||||
|
lowerKey === 'jadwal_name' ||
|
||||||
|
lowerKey === 'jadwal_id' ||
|
||||||
|
lowerKey === 'schedulename' ||
|
||||||
|
lowerKey === 'scheduleid' ||
|
||||||
|
lowerKey === 'pompa_air' ||
|
||||||
|
lowerKey === 'pompaair' ||
|
||||||
|
lowerKey === 'pompa_pupuk' ||
|
||||||
|
lowerKey === 'pompapupuk' ||
|
||||||
|
lowerKey === 'pompa_pengaduk' ||
|
||||||
|
lowerKey === 'pompapengaduk' ||
|
||||||
|
lowerKey === 'water_pump' ||
|
||||||
|
lowerKey === 'fertilizer_pump' ||
|
||||||
|
lowerKey === 'mixer_pump';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateKey(value) {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimeKey(value) {
|
||||||
|
return /^\d{2}:\d{2}(:\d{2})?$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogObject(entry, context) {
|
||||||
|
const resolvedDateTime = resolveDateTime(entry, context);
|
||||||
|
const temperature = normalizeNumber(entry.suhu ?? entry.temp ?? entry.temperature);
|
||||||
|
const light = normalizeLightValue(entry.ldr ?? entry.light ?? entry.cahaya);
|
||||||
|
const waterFlow = normalizeNumber(entry.water_flow ?? entry.waterFlow ?? entry.flow);
|
||||||
|
const status = resolveStatus(entry, waterFlow);
|
||||||
|
const source = entry.source ?? entry.mode ?? entry.activity ?? entry.event ?? '-';
|
||||||
|
const type = entry.type ?? entry.event_type ?? entry.activity_type ?? entry.action ?? '-';
|
||||||
|
|
||||||
|
// Get allowed pots for filtering based on schedule/threshold
|
||||||
|
const allowedPots = getAllowedPotsForEvent(entry);
|
||||||
|
|
||||||
|
// DEBUG: Log penyiraman events untuk melihat struktur data
|
||||||
|
if (type && (String(type).toLowerCase().includes('waktu_jadwal') || String(type).toLowerCase().includes('sensor_threshold'))) {
|
||||||
|
console.log('🔍 Penyiraman Event:', {
|
||||||
|
time: resolvedDateTime.time,
|
||||||
|
type: type,
|
||||||
|
jadwal_id: entry.jadwal_id,
|
||||||
|
threshold_id: entry.threshold_id,
|
||||||
|
allowedPots: allowedPots,
|
||||||
|
allKeys: Object.keys(entry)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const potRows = extractPotRows(entry, allowedPots);
|
||||||
|
|
||||||
|
if (potRows.length > 0) {
|
||||||
|
return potRows.map((potItem) => ({
|
||||||
|
date: resolvedDateTime.date,
|
||||||
|
time: resolvedDateTime.time,
|
||||||
|
datetime: resolvedDateTime.datetime,
|
||||||
|
sortTimestamp: resolvedDateTime.sortTimestamp,
|
||||||
|
timestamp: resolvedDateTime.timestamp,
|
||||||
|
pot: potItem.pot,
|
||||||
|
temp: temperature,
|
||||||
|
light,
|
||||||
|
moisture: potItem.moisture,
|
||||||
|
waterFlow,
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
type,
|
||||||
|
raw: entry
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackPot = normalizeNumber(entry.pot ?? entry.pot_id ?? entry.potNumber);
|
||||||
|
const fallbackMoisture = normalizeNumber(entry.kelembapan ?? entry.moisture);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
date: resolvedDateTime.date,
|
||||||
|
time: resolvedDateTime.time,
|
||||||
|
datetime: resolvedDateTime.datetime,
|
||||||
|
sortTimestamp: resolvedDateTime.sortTimestamp,
|
||||||
|
timestamp: resolvedDateTime.timestamp,
|
||||||
|
pot: fallbackPot,
|
||||||
|
temp: temperature,
|
||||||
|
light,
|
||||||
|
moisture: fallbackMoisture,
|
||||||
|
waterFlow,
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
type,
|
||||||
|
raw: entry
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPotRows(entry, allowedPots = null) {
|
||||||
|
const rows = [];
|
||||||
|
const seenPots = new Set();
|
||||||
|
|
||||||
|
Object.entries(entry).forEach(([key, value]) => {
|
||||||
|
const match = key.match(/^soil[_-]?(\d+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pot = parseInt(match[1], 10);
|
||||||
|
const moisture = normalizeNumber(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(pot) || moisture === null || seenPots.has(pot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter pot if allowedPots is specified (watering event with schedule/threshold info)
|
||||||
|
if (allowedPots !== null && !allowedPots.includes(pot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPots.add(pot);
|
||||||
|
rows.push({ pot, moisture });
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => a.pot - b.pot);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDateTime(entry, context) {
|
||||||
|
const timestampRaw = normalizeNumber(entry.timestamp ?? entry.ts);
|
||||||
|
const timestamp = normalizeTimestamp(timestampRaw);
|
||||||
|
const dateFromTimestamp = timestamp ? new Date(timestamp) : null;
|
||||||
|
|
||||||
|
const date = context.date || normalizeDateString(entry.date) || (dateFromTimestamp ? formatDate(dateFromTimestamp) : '-');
|
||||||
|
const time = context.time || normalizeTimeString(entry.time) || (dateFromTimestamp ? formatTime(dateFromTimestamp) : '-');
|
||||||
|
const datetime = `${date} ${time}`.trim();
|
||||||
|
const sortTimestamp = timestamp || buildTimestampFromDateTime(date, time);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
datetime,
|
||||||
|
timestamp,
|
||||||
|
sortTimestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value) {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > 0 && value < 100000000000) {
|
||||||
|
return Math.round(value * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimestampFromDateTime(date, time) {
|
||||||
|
if (!isDateKey(date)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeTime = isTimeKey(time) ? normalizeTimeString(time) : '00:00';
|
||||||
|
const parsed = new Date(`${date}T${safeTime}:00`);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateString(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDateKey(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimeString(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/^(\d{2}):(\d{2})(?::\d{2})?$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${match[1]}:${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateObject) {
|
||||||
|
const year = dateObject.getFullYear();
|
||||||
|
const month = String(dateObject.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObject.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateObject) {
|
||||||
|
const hours = String(dateObject.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(dateObject.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLightValue(value) {
|
||||||
|
const numericValue = normalizeNumber(value);
|
||||||
|
if (numericValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericValue >= 0 && numericValue <= 1) {
|
||||||
|
return Math.round(numericValue * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericValue > 1 && numericValue <= 100) {
|
||||||
|
return Math.round(numericValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericValue > 100 && numericValue <= 1023) {
|
||||||
|
return Math.round((numericValue / 1023) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(numericValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatus(entry, waterFlow) {
|
||||||
|
const type = String(entry.type ?? '').toLowerCase().trim();
|
||||||
|
|
||||||
|
// Deteksi Penyiraman: mode waktu atau sensor threshold
|
||||||
|
if (type.includes('waktu_jadwal') || type.includes('sensor_threshold')) {
|
||||||
|
return 'Menyiram';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deteksi Monitoring: auto_log
|
||||||
|
if (type === 'auto_log') {
|
||||||
|
return 'Normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityType(item) {
|
||||||
|
const status = item.status;
|
||||||
|
if (status === 'Menyiram') {
|
||||||
|
return 'Penyiraman';
|
||||||
|
}
|
||||||
|
return 'Monitoring';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityBadgeClass(activityType) {
|
||||||
|
return activityType === 'Penyiraman' ? 'activity-watering' : 'activity-monitoring';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayValue(value, suffix = '', decimals = 0) {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = decimals > 0 ? value.toFixed(decimals) : Math.round(value).toString();
|
||||||
|
return `${formatted}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display history data in table with pagination
|
||||||
|
function displayHistoryData() {
|
||||||
|
const tbody = document.getElementById('historyTableBody');
|
||||||
|
if (!tbody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (filteredData.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 40px;">Tidak ada data</td></tr>';
|
||||||
|
updatePaginationInfo(0, 0, 0);
|
||||||
|
updatePaginationButtons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + itemsPerPage, filteredData.length);
|
||||||
|
const pageData = filteredData.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Display data
|
||||||
|
pageData.forEach((item) => {
|
||||||
|
const potCell = Number.isFinite(item.pot)
|
||||||
|
? `<span class="pot-badge pot-${item.pot}-badge">Pot ${item.pot}</span>`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const activityType = getActivityType(item);
|
||||||
|
const badgeClass = getActivityBadgeClass(activityType);
|
||||||
|
const activityBadge = `<span class="activity-badge ${badgeClass}">${activityType}</span>`;
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${item.datetime}</td>
|
||||||
|
<td>${potCell}</td>
|
||||||
|
<td>${formatDisplayValue(item.temp, ' C', 1)}</td>
|
||||||
|
<td>${formatDisplayValue(item.light, '%')}</td>
|
||||||
|
<td>${formatDisplayValue(item.moisture, '%')}</td>
|
||||||
|
<td>${activityBadge}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update pagination info and controls
|
||||||
|
updatePaginationInfo(startIndex + 1, endIndex, filteredData.length);
|
||||||
|
updatePaginationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
function updateStatistics(data) {
|
||||||
|
// Statistics cards have been removed, this function is kept for compatibility
|
||||||
|
// but doesn't update any elements anymore
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFilterHandlers() {
|
||||||
|
const applyFilterBtn = document.getElementById('applyFilter');
|
||||||
|
if (applyFilterBtn) {
|
||||||
|
applyFilterBtn.addEventListener('click', () => {
|
||||||
|
applyFiltersAndSearch(true);
|
||||||
|
showNotification('Filter diterapkan', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
applyFiltersAndSearch(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiltersAndSearch(resetPage) {
|
||||||
|
const potFilter = document.getElementById('potFilter')?.value || 'all';
|
||||||
|
const searchTerm = (document.getElementById('searchInput')?.value || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
filteredData = historyData.filter((item) => {
|
||||||
|
const passPot = potFilter === 'all' || item.pot === parseInt(potFilter, 10);
|
||||||
|
if (!passPot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchableText = [
|
||||||
|
item.datetime,
|
||||||
|
item.date,
|
||||||
|
item.time,
|
||||||
|
item.pot,
|
||||||
|
item.temp,
|
||||||
|
item.light,
|
||||||
|
item.moisture,
|
||||||
|
item.status,
|
||||||
|
item.type,
|
||||||
|
item.source
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
return searchableText.includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resetPage) {
|
||||||
|
currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayHistoryData();
|
||||||
|
updateStatistics(filteredData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup pagination controls
|
||||||
|
function setupPagination() {
|
||||||
|
// Entries per page
|
||||||
|
const entriesSelect = document.getElementById('entriesPerPage');
|
||||||
|
if (entriesSelect) {
|
||||||
|
entriesSelect.addEventListener('change', function () {
|
||||||
|
itemsPerPage = parseInt(this.value, 10);
|
||||||
|
currentPage = 1;
|
||||||
|
displayHistoryData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous page
|
||||||
|
const prevBtn = document.getElementById('prevPage');
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.addEventListener('click', () => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
displayHistoryData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next page
|
||||||
|
const nextBtn = document.getElementById('nextPage');
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.addEventListener('click', () => {
|
||||||
|
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
displayHistoryData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pagination info text
|
||||||
|
function updatePaginationInfo(start, end, total) {
|
||||||
|
const info = document.getElementById('paginationInfo');
|
||||||
|
if (!info) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
info.textContent = 'Showing 0 to 0 of 0 entries';
|
||||||
|
} else {
|
||||||
|
info.textContent = `Showing ${start} to ${end} of ${total} entries`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pagination buttons state
|
||||||
|
function updatePaginationButtons() {
|
||||||
|
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||||
|
|
||||||
|
// Disable/enable buttons
|
||||||
|
const prevBtn = document.getElementById('prevPage');
|
||||||
|
const nextBtn = document.getElementById('nextPage');
|
||||||
|
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.disabled = currentPage === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.disabled = currentPage === totalPages || totalPages === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page numbers
|
||||||
|
updatePageNumbers(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page number buttons
|
||||||
|
function updatePageNumbers(totalPages) {
|
||||||
|
const pageNumbersContainer = document.getElementById('pageNumbers');
|
||||||
|
if (!pageNumbersContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageNumbersContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (totalPages === 0) return;
|
||||||
|
|
||||||
|
// Show max 3 page numbers
|
||||||
|
let startPage = Math.max(1, currentPage - 1);
|
||||||
|
let endPage = Math.min(totalPages, startPage + 2);
|
||||||
|
|
||||||
|
// Adjust if we're near the end
|
||||||
|
if (endPage - startPage < 2) {
|
||||||
|
startPage = Math.max(1, endPage - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const pageBtn = document.createElement('button');
|
||||||
|
pageBtn.className = 'btn-page-number';
|
||||||
|
pageBtn.textContent = i;
|
||||||
|
|
||||||
|
if (i === currentPage) {
|
||||||
|
pageBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
pageBtn.addEventListener('click', () => {
|
||||||
|
currentPage = i;
|
||||||
|
displayHistoryData();
|
||||||
|
});
|
||||||
|
|
||||||
|
pageNumbersContainer.appendChild(pageBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add next indicator if there are more pages
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
const dots = document.createElement('span');
|
||||||
|
dots.className = 'page-dots';
|
||||||
|
dots.textContent = '>';
|
||||||
|
pageNumbersContainer.appendChild(dots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-analytics.js";
|
||||||
|
import { getAuth, createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
import { getFirestore, collection, getDocs, setDoc, doc, deleteDoc } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-firestore.js";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
// Notification function
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
const messageEl = notification.querySelector('.notification-message');
|
||||||
|
|
||||||
|
// Remove previous classes
|
||||||
|
notification.classList.remove('success', 'error', 'warning', 'hidden');
|
||||||
|
|
||||||
|
// Add new class and message
|
||||||
|
notification.classList.add(type);
|
||||||
|
messageEl.textContent = message;
|
||||||
|
|
||||||
|
// Auto hide after 4 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('dashboardPage').hidden = false;
|
||||||
|
document.getElementById('loginMessage').hidden = true;
|
||||||
|
document.getElementById('userEmail').textContent = user.email;
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
document.getElementById('dashboardPage').hidden = true;
|
||||||
|
document.getElementById('loginMessage').hidden = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign out function
|
||||||
|
document.getElementById('signOutBtn').addEventListener('click', () => {
|
||||||
|
signOut(auth).then(() => {
|
||||||
|
console.log('User signed out');
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add account function
|
||||||
|
document.getElementById('addAccountBtn').addEventListener('click', async () => {
|
||||||
|
const email = document.getElementById('newAccountEmail').value;
|
||||||
|
const password = document.getElementById('newAccountPassword').value;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
showNotification('Email dan password harus diisi!', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
showNotification('Password harus minimal 6 karakter!', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable button to prevent multiple clicks
|
||||||
|
const addBtn = document.getElementById('addAccountBtn');
|
||||||
|
addBtn.disabled = true;
|
||||||
|
addBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Memproses...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simpan kredensial admin saat ini
|
||||||
|
const currentUser = auth.currentUser;
|
||||||
|
const adminEmail = currentUser.email;
|
||||||
|
|
||||||
|
// Buat instance auth kedua untuk user baru
|
||||||
|
const secondaryApp = initializeApp(firebaseConfig, 'Secondary');
|
||||||
|
const secondaryAuth = getAuth(secondaryApp);
|
||||||
|
|
||||||
|
// Buat akun baru menggunakan auth instance kedua
|
||||||
|
const userCredential = await createUserWithEmailAndPassword(secondaryAuth, email, password);
|
||||||
|
const newUser = userCredential.user;
|
||||||
|
console.log('Akun baru berhasil dibuat:', newUser);
|
||||||
|
|
||||||
|
// Simpan data user ke Firestore
|
||||||
|
await setDoc(doc(db, 'users', newUser.uid), {
|
||||||
|
email: email,
|
||||||
|
uid: newUser.uid,
|
||||||
|
createdAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout dari auth instance kedua
|
||||||
|
await signOut(secondaryAuth);
|
||||||
|
|
||||||
|
// Hapus secondary app
|
||||||
|
await secondaryApp.delete();
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
document.getElementById('newAccountEmail').value = '';
|
||||||
|
document.getElementById('newAccountPassword').value = '';
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
showNotification(`Akun ${email} berhasil ditambahkan!`, 'success');
|
||||||
|
|
||||||
|
// Reload user list
|
||||||
|
loadUsers();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding account:', error);
|
||||||
|
let errorMessage = 'Terjadi kesalahan saat menambah akun';
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if (error.code === 'auth/email-already-in-use') {
|
||||||
|
errorMessage = 'Email sudah digunakan!';
|
||||||
|
} else if (error.code === 'auth/invalid-email') {
|
||||||
|
errorMessage = 'Format email tidak valid!';
|
||||||
|
} else if (error.code === 'auth/weak-password') {
|
||||||
|
errorMessage = 'Password terlalu lemah!';
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(errorMessage, 'error');
|
||||||
|
} finally {
|
||||||
|
// Re-enable button
|
||||||
|
addBtn.disabled = false;
|
||||||
|
addBtn.innerHTML = '<i class="fas fa-plus-circle"></i> Tambah User';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load users from Firestore
|
||||||
|
async function loadUsers() {
|
||||||
|
const userTableBody = document.getElementById('userTableBody');
|
||||||
|
userTableBody.innerHTML = '<tr><td colspan="4" class="text-center">Loading...</td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const querySnapshot = await getDocs(collection(db, 'users'));
|
||||||
|
userTableBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (querySnapshot.empty) {
|
||||||
|
userTableBody.innerHTML = '<tr><td colspan="4" class="text-center">Belum ada user</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let no = 1;
|
||||||
|
querySnapshot.forEach((docSnapshot) => {
|
||||||
|
const user = docSnapshot.data();
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
const createdDate = user.createdAt ? new Date(user.createdAt.seconds * 1000).toLocaleDateString('id-ID') : 'N/A';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${no++}</td>
|
||||||
|
<td>${user.email}</td>
|
||||||
|
<td>${createdDate}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-delete" onclick="deleteUser('${user.uid}', '${user.email}')">
|
||||||
|
<i class="fas fa-trash"></i> Hapus
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
userTableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error);
|
||||||
|
userTableBody.innerHTML = '<tr><td colspan="4" class="text-center">Error loading users</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user function
|
||||||
|
window.deleteUser = async function(userId, email) {
|
||||||
|
if (confirm(`Apakah Anda yakin ingin menghapus user ${email}?`)) {
|
||||||
|
try {
|
||||||
|
await deleteDoc(doc(db, 'users', userId));
|
||||||
|
alert('User berhasil dihapus dari database!');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-analytics.js";
|
||||||
|
import { getAuth, signInWithEmailAndPassword, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
|
||||||
|
// Check if already logged in
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
// Redirect to dashboard if already logged in
|
||||||
|
window.location.href = '../dashboard/';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in function
|
||||||
|
document.getElementById('signInBtn').addEventListener('click', () => {
|
||||||
|
const email = document.getElementById('emailInput').value;
|
||||||
|
const password = document.getElementById('passwordInput').value;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
alert('Email dan password harus diisi!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signInWithEmailAndPassword(auth, email, password)
|
||||||
|
.then((userCredential) => {
|
||||||
|
console.log('User signed in:', userCredential.user);
|
||||||
|
// Redirect to dashboard
|
||||||
|
window.location.href = '../dashboard/';
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Sign in error:', error.message);
|
||||||
|
alert('Login gagal: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow Enter key to submit
|
||||||
|
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
document.getElementById('signInBtn').click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('emailInput').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
document.getElementById('signInBtn').click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply .scrolled class to the body as the page is scrolled down
|
||||||
|
*/
|
||||||
|
function toggleScrolled() {
|
||||||
|
const selectBody = document.querySelector('body');
|
||||||
|
const selectHeader = document.querySelector('#header');
|
||||||
|
if (!selectHeader.classList.contains('scroll-up-sticky') && !selectHeader.classList.contains('sticky-top') && !selectHeader.classList.contains('fixed-top')) return;
|
||||||
|
window.scrollY > 100 ? selectBody.classList.add('scrolled') : selectBody.classList.remove('scrolled');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('scroll', toggleScrolled);
|
||||||
|
window.addEventListener('load', toggleScrolled);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile nav toggle
|
||||||
|
*/
|
||||||
|
const mobileNavToggleBtn = document.querySelector('.mobile-nav-toggle');
|
||||||
|
|
||||||
|
function mobileNavToogle() {
|
||||||
|
document.querySelector('body').classList.toggle('mobile-nav-active');
|
||||||
|
mobileNavToggleBtn.classList.toggle('bi-list');
|
||||||
|
mobileNavToggleBtn.classList.toggle('bi-x');
|
||||||
|
}
|
||||||
|
if (mobileNavToggleBtn) {
|
||||||
|
mobileNavToggleBtn.addEventListener('click', mobileNavToogle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide mobile nav on same-page/hash links
|
||||||
|
*/
|
||||||
|
document.querySelectorAll('#navmenu a').forEach(navmenu => {
|
||||||
|
navmenu.addEventListener('click', () => {
|
||||||
|
if (document.querySelector('.mobile-nav-active')) {
|
||||||
|
mobileNavToogle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mobile nav dropdowns
|
||||||
|
*/
|
||||||
|
document.querySelectorAll('.navmenu .toggle-dropdown').forEach(navmenu => {
|
||||||
|
navmenu.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.parentNode.classList.toggle('active');
|
||||||
|
this.parentNode.nextElementSibling.classList.toggle('dropdown-active');
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloader
|
||||||
|
*/
|
||||||
|
const preloader = document.querySelector('#preloader');
|
||||||
|
if (preloader) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
preloader.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll top button
|
||||||
|
*/
|
||||||
|
let scrollTop = document.querySelector('.scroll-top');
|
||||||
|
|
||||||
|
function toggleScrollTop() {
|
||||||
|
if (scrollTop) {
|
||||||
|
window.scrollY > 100 ? scrollTop.classList.add('active') : scrollTop.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scrollTop.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('load', toggleScrollTop);
|
||||||
|
document.addEventListener('scroll', toggleScrollTop);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation on scroll function and init
|
||||||
|
*/
|
||||||
|
function aosInit() {
|
||||||
|
AOS.init({
|
||||||
|
duration: 600,
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
once: true,
|
||||||
|
mirror: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('load', aosInit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate glightbox
|
||||||
|
*/
|
||||||
|
const glightbox = GLightbox({
|
||||||
|
selector: '.glightbox'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init swiper sliders
|
||||||
|
*/
|
||||||
|
function initSwiper() {
|
||||||
|
document.querySelectorAll(".init-swiper").forEach(function(swiperElement) {
|
||||||
|
let config = JSON.parse(
|
||||||
|
swiperElement.querySelector(".swiper-config").innerHTML.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (swiperElement.classList.contains("swiper-tab")) {
|
||||||
|
initSwiperWithCustomPagination(swiperElement, config);
|
||||||
|
} else {
|
||||||
|
new Swiper(swiperElement, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", initSwiper);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequently Asked Questions Toggle
|
||||||
|
*/
|
||||||
|
document.querySelectorAll('.faq-item h3, .faq-item .faq-toggle').forEach((faqItem) => {
|
||||||
|
faqItem.addEventListener('click', () => {
|
||||||
|
faqItem.parentNode.classList.toggle('faq-active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate the skills items on reveal
|
||||||
|
*/
|
||||||
|
let skillsAnimation = document.querySelectorAll('.skills-animation');
|
||||||
|
skillsAnimation.forEach((item) => {
|
||||||
|
new Waypoint({
|
||||||
|
element: item,
|
||||||
|
offset: '80%',
|
||||||
|
handler: function(direction) {
|
||||||
|
let progress = item.querySelectorAll('.progress .progress-bar');
|
||||||
|
progress.forEach(el => {
|
||||||
|
el.style.width = el.getAttribute('aria-valuenow') + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init isotope layout and filters
|
||||||
|
*/
|
||||||
|
document.querySelectorAll('.isotope-layout').forEach(function(isotopeItem) {
|
||||||
|
let layout = isotopeItem.getAttribute('data-layout') ?? 'masonry';
|
||||||
|
let filter = isotopeItem.getAttribute('data-default-filter') ?? '*';
|
||||||
|
let sort = isotopeItem.getAttribute('data-sort') ?? 'original-order';
|
||||||
|
|
||||||
|
let initIsotope;
|
||||||
|
imagesLoaded(isotopeItem.querySelector('.isotope-container'), function() {
|
||||||
|
initIsotope = new Isotope(isotopeItem.querySelector('.isotope-container'), {
|
||||||
|
itemSelector: '.isotope-item',
|
||||||
|
layoutMode: layout,
|
||||||
|
filter: filter,
|
||||||
|
sortBy: sort
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
isotopeItem.querySelectorAll('.isotope-filters li').forEach(function(filters) {
|
||||||
|
filters.addEventListener('click', function() {
|
||||||
|
isotopeItem.querySelector('.isotope-filters .filter-active').classList.remove('filter-active');
|
||||||
|
this.classList.add('filter-active');
|
||||||
|
initIsotope.arrange({
|
||||||
|
filter: this.getAttribute('data-filter')
|
||||||
|
});
|
||||||
|
if (typeof aosInit === 'function') {
|
||||||
|
aosInit();
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct scrolling position upon page load for URLs containing hash links.
|
||||||
|
*/
|
||||||
|
window.addEventListener('load', function(e) {
|
||||||
|
if (window.location.hash) {
|
||||||
|
if (document.querySelector(window.location.hash)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
let section = document.querySelector(window.location.hash);
|
||||||
|
let scrollMarginTop = getComputedStyle(section).scrollMarginTop;
|
||||||
|
window.scrollTo({
|
||||||
|
top: section.offsetTop - parseInt(scrollMarginTop),
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navmenu Scrollspy
|
||||||
|
*/
|
||||||
|
let navmenulinks = document.querySelectorAll('.navmenu a');
|
||||||
|
|
||||||
|
function navmenuScrollspy() {
|
||||||
|
navmenulinks.forEach(navmenulink => {
|
||||||
|
if (!navmenulink.hash) return;
|
||||||
|
let section = document.querySelector(navmenulink.hash);
|
||||||
|
if (!section) return;
|
||||||
|
let position = window.scrollY + 200;
|
||||||
|
if (position >= section.offsetTop && position <= (section.offsetTop + section.offsetHeight)) {
|
||||||
|
document.querySelectorAll('.navmenu a.active').forEach(link => link.classList.remove('active'));
|
||||||
|
navmenulink.classList.add('active');
|
||||||
|
} else {
|
||||||
|
navmenulink.classList.remove('active');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener('load', navmenuScrollspy);
|
||||||
|
document.addEventListener('scroll', navmenuScrollspy);
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,673 @@
|
||||||
|
// Import Firebase
|
||||||
|
import { initializeApp } from 'https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js';
|
||||||
|
import { getAuth, onAuthStateChanged, signOut } from 'https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js';
|
||||||
|
import { getDatabase, ref, onValue, update } from 'https://www.gstatic.com/firebasejs/12.7.0/firebase-database.js';
|
||||||
|
|
||||||
|
// Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: 'AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig',
|
||||||
|
authDomain: 'project-ta-951b4.firebaseapp.com',
|
||||||
|
databaseURL: 'https://project-ta-951b4-default-rtdb.firebaseio.com',
|
||||||
|
projectId: 'project-ta-951b4',
|
||||||
|
storageBucket: 'project-ta-951b4.firebasestorage.app',
|
||||||
|
messagingSenderId: '217854138058',
|
||||||
|
appId: '1:217854138058:web:50a5bcd5a61ac1820c4633',
|
||||||
|
measurementId: 'G-6ML8QQEGNZ'
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const database = getDatabase(app);
|
||||||
|
|
||||||
|
const KONTROL_BASE_PATH = 'kontrol_1';
|
||||||
|
const kontrolRef = ref(database, KONTROL_BASE_PATH);
|
||||||
|
|
||||||
|
let activeThresholdKey = 'threshold_1';
|
||||||
|
let thresholdProfiles = ['threshold_1'];
|
||||||
|
let lastKontrolData = {};
|
||||||
|
let mainModeActive = false;
|
||||||
|
let isPageLoaded = false;
|
||||||
|
|
||||||
|
function clampNumber(value, min, max) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(parsed, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePotValue(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'on', 'aktif', 'yes', 'y'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['0', 'false', 'off', 'nonaktif', 'no', 'n', ''].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(normalized);
|
||||||
|
if (!Number.isNaN(numeric)) {
|
||||||
|
return numeric !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThresholdNumber(thresholdKey) {
|
||||||
|
const match = String(thresholdKey || '').match(/^threshold_(\d+)$/i);
|
||||||
|
return match ? parseInt(match[1], 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThresholdLabel(thresholdKey) {
|
||||||
|
return `Threshold ${getThresholdNumber(thresholdKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractThresholdKeys(data) {
|
||||||
|
const keys = Object.keys(data || {})
|
||||||
|
.filter((key) => /^threshold_\d+$/i.test(key))
|
||||||
|
.sort((a, b) => getThresholdNumber(a) - getThresholdNumber(b));
|
||||||
|
|
||||||
|
return keys.length > 0 ? keys : ['threshold_1'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThresholdKey(value) {
|
||||||
|
if (!value) {
|
||||||
|
return thresholdProfiles[0] || 'threshold_1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return thresholdProfiles.includes(value) ? value : (thresholdProfiles[0] || 'threshold_1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePotAktif(potAktifSource = {}) {
|
||||||
|
if (Array.isArray(potAktifSource)) {
|
||||||
|
const normalized = { pot_1: false, pot_2: false, pot_3: false, pot_4: false, pot_5: false };
|
||||||
|
potAktifSource.forEach((value) => {
|
||||||
|
const potNumber = Number(value);
|
||||||
|
if (Number.isFinite(potNumber) && potNumber >= 1 && potNumber <= 5) {
|
||||||
|
normalized[`pot_${potNumber}`] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (potAktifSource && typeof potAktifSource === 'object') {
|
||||||
|
const numericKeys = Object.keys(potAktifSource).filter((key) => /^\d+$/.test(key));
|
||||||
|
if (numericKeys.length > 0) {
|
||||||
|
const normalized = { pot_1: false, pot_2: false, pot_3: false, pot_4: false, pot_5: false };
|
||||||
|
Object.values(potAktifSource).forEach((value) => {
|
||||||
|
const potNumber = Number(value);
|
||||||
|
if (Number.isFinite(potNumber) && potNumber >= 1 && potNumber <= 5) {
|
||||||
|
normalized[`pot_${potNumber}`] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pot_1: normalizePotValue(potAktifSource.pot_1 ?? potAktifSource.pot1),
|
||||||
|
pot_2: normalizePotValue(potAktifSource.pot_2 ?? potAktifSource.pot2),
|
||||||
|
pot_3: normalizePotValue(potAktifSource.pot_3 ?? potAktifSource.pot3),
|
||||||
|
pot_4: normalizePotValue(potAktifSource.pot_4 ?? potAktifSource.pot4),
|
||||||
|
pot_5: normalizePotValue(potAktifSource.pot_5 ?? potAktifSource.pot5)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThresholdDataByKey(data, thresholdKey) {
|
||||||
|
const source = data?.[thresholdKey] || {};
|
||||||
|
const batasBawah = Number(source.batas_bawah);
|
||||||
|
const batasAtas = Number(source.batas_atas);
|
||||||
|
const namaTanaman = String(source.nama_tanaman || '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
nama_tanaman: namaTanaman || getThresholdLabel(thresholdKey),
|
||||||
|
batas_bawah: Number.isFinite(batasBawah) ? batasBawah : 30,
|
||||||
|
batas_atas: Number.isFinite(batasAtas) ? batasAtas : 70,
|
||||||
|
pot_aktif: normalizePotAktif(source.pot_aktif),
|
||||||
|
pompa_air: source.pompa_air === true,
|
||||||
|
pompa_pupuk: source.pompa_pupuk === true,
|
||||||
|
pompa_pengaduk: source.pompa_pengaduk === true,
|
||||||
|
aktif: source.aktif === true,
|
||||||
|
smart_mode: source.smart_mode === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveThresholdData(data) {
|
||||||
|
return getThresholdDataByKey(data, activeThresholdKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextThresholdKey() {
|
||||||
|
const indices = thresholdProfiles
|
||||||
|
.map(getThresholdNumber)
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
|
||||||
|
const nextIndex = indices.length > 0 ? Math.max(...indices) + 1 : 1;
|
||||||
|
return `threshold_${nextIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPumpType(thresholdData) {
|
||||||
|
if (thresholdData.pompa_pengaduk) {
|
||||||
|
return 'pengaduk';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thresholdData.pompa_pupuk) {
|
||||||
|
return 'nutrisi';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'air';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPumpLabelByType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'nutrisi':
|
||||||
|
return 'Pompa Nutrisi';
|
||||||
|
case 'pengaduk':
|
||||||
|
return 'Pengaduk';
|
||||||
|
default:
|
||||||
|
return 'Pompa Air';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPumpIconByType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'nutrisi':
|
||||||
|
return 'fas fa-flask';
|
||||||
|
case 'pengaduk':
|
||||||
|
return 'fas fa-blender';
|
||||||
|
default:
|
||||||
|
return 'fas fa-water';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPotSummary(thresholdData) {
|
||||||
|
const selectedPots = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
if (thresholdData.pot_aktif[`pot_${i}`] === true) {
|
||||||
|
selectedPots.push(`Pot ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedPots.length > 0 ? selectedPots.join(', ') : 'Belum ada pot dipilih';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMainModeVisual(isActive) {
|
||||||
|
const mainToggle = document.getElementById('mainToggle');
|
||||||
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const addThresholdBtn = document.getElementById('btnAddThreshold');
|
||||||
|
const saveThresholdBtn = document.getElementById('btnSaveThreshold');
|
||||||
|
|
||||||
|
if (mainToggle) {
|
||||||
|
mainToggle.classList.toggle('active', isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusIndicator) {
|
||||||
|
statusIndicator.classList.toggle('active', isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) {
|
||||||
|
statusText.textContent = isActive ? 'Aktif' : 'Tidak Aktif';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addThresholdBtn) {
|
||||||
|
addThresholdBtn.disabled = !isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveThresholdBtn) {
|
||||||
|
saveThresholdBtn.disabled = !isActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThresholdProfileUI() {
|
||||||
|
const activeLabel = document.getElementById('activeThresholdLabel');
|
||||||
|
const container = document.getElementById('thresholdProfileContainer');
|
||||||
|
|
||||||
|
if (activeLabel) {
|
||||||
|
const activeData = getThresholdDataByKey(lastKontrolData, activeThresholdKey);
|
||||||
|
activeLabel.textContent = `Profil aktif: ${getThresholdLabel(activeThresholdKey)} - ${activeData.nama_tanaman}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = thresholdProfiles.map((thresholdKey) => {
|
||||||
|
const thresholdData = getThresholdDataByKey(lastKontrolData, thresholdKey);
|
||||||
|
const isActive = thresholdKey === activeThresholdKey;
|
||||||
|
const pumpType = getPumpType(thresholdData);
|
||||||
|
const deleteDisabled = thresholdProfiles.length <= 1 || !mainModeActive;
|
||||||
|
const safePlantName = escapeHtml(thresholdData.nama_tanaman);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="threshold-profile-item ${isActive ? 'active' : ''}">
|
||||||
|
<div class="threshold-profile-item-main">
|
||||||
|
<div class="threshold-profile-item-title-row">
|
||||||
|
<div class="threshold-profile-item-title">${getThresholdLabel(thresholdKey)}</div>
|
||||||
|
${isActive ? '<span class="threshold-active-badge">Aktif</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="threshold-profile-item-meta"><i class="fas fa-tag"></i> Nama: ${safePlantName}</div>
|
||||||
|
<div class="threshold-profile-item-meta"><i class="fas fa-sliders-h"></i> Batas: ${thresholdData.batas_bawah}% - ${thresholdData.batas_atas}%</div>
|
||||||
|
<div class="threshold-profile-item-meta"><i class="fas fa-seedling"></i> Pot: ${getPotSummary(thresholdData)}</div>
|
||||||
|
<div class="threshold-profile-item-meta"><i class="${getPumpIconByType(pumpType)}"></i> Penyiraman: ${getPumpLabelByType(pumpType)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="threshold-profile-item-actions">
|
||||||
|
<button type="button" class="threshold-action-btn btn-activate-threshold ${isActive ? 'active' : ''}" data-threshold-action="select" data-threshold-key="${thresholdKey}" ${!mainModeActive ? 'disabled' : ''}>${isActive ? 'Aktif' : 'Pilih'}</button>
|
||||||
|
<button type="button" class="threshold-action-btn btn-edit-threshold" data-threshold-action="edit" data-threshold-key="${thresholdKey}" ${!mainModeActive ? 'disabled' : ''}>Edit</button>
|
||||||
|
<button type="button" class="threshold-action-btn btn-delete-threshold" data-threshold-action="delete" data-threshold-key="${thresholdKey}" ${deleteDisabled ? 'disabled' : ''}>Hapus</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchThresholdProfile(selectedKey) {
|
||||||
|
if (!mainModeActive) {
|
||||||
|
showNotification('Aktifkan Mode Otomatis terlebih dahulu', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdKey = resolveThresholdKey(selectedKey);
|
||||||
|
if (thresholdKey === activeThresholdKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousKey = activeThresholdKey;
|
||||||
|
activeThresholdKey = thresholdKey;
|
||||||
|
updateThresholdProfileUI();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await update(kontrolRef, {
|
||||||
|
threshold_aktif: thresholdKey
|
||||||
|
});
|
||||||
|
showNotification(`Berpindah ke ${getThresholdLabel(thresholdKey)}`, 'info');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error switching threshold profile:', error);
|
||||||
|
activeThresholdKey = previousKey;
|
||||||
|
updateThresholdProfileUI();
|
||||||
|
showNotification('Gagal mengganti profil threshold', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openThresholdModal(thresholdKey = null) {
|
||||||
|
if (!mainModeActive) {
|
||||||
|
showNotification('Aktifkan Mode Otomatis terlebih dahulu', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('thresholdModal');
|
||||||
|
const title = document.getElementById('thresholdModalTitle');
|
||||||
|
const editInput = document.getElementById('editThresholdKey');
|
||||||
|
const plantNameInput = document.getElementById('thresholdPlantName');
|
||||||
|
const minInput = document.getElementById('thresholdMinInput');
|
||||||
|
const maxInput = document.getElementById('thresholdMaxInput');
|
||||||
|
|
||||||
|
if (!modal || !title || !editInput || !plantNameInput || !minInput || !maxInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEditMode = Boolean(thresholdKey);
|
||||||
|
const sourceData = isEditMode
|
||||||
|
? getThresholdDataByKey(lastKontrolData, thresholdKey)
|
||||||
|
: getActiveThresholdData(lastKontrolData);
|
||||||
|
|
||||||
|
title.innerHTML = isEditMode
|
||||||
|
? '<i class="fas fa-edit"></i> Edit Threshold'
|
||||||
|
: '<i class="fas fa-layer-group"></i> Tambah Threshold';
|
||||||
|
|
||||||
|
editInput.value = isEditMode ? thresholdKey : '';
|
||||||
|
plantNameInput.value = isEditMode ? (sourceData.nama_tanaman || '') : '';
|
||||||
|
minInput.value = sourceData.batas_bawah;
|
||||||
|
maxInput.value = sourceData.batas_atas;
|
||||||
|
|
||||||
|
const potCheckboxes = document.querySelectorAll('.threshold-pot-checkbox');
|
||||||
|
potCheckboxes.forEach((checkbox) => {
|
||||||
|
const potNumber = checkbox.value.replace('pot', '');
|
||||||
|
checkbox.checked = sourceData.pot_aktif[`pot_${potNumber}`] === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pumpType = getPumpType(sourceData);
|
||||||
|
const pumpRadio = document.querySelector(`input[name="thresholdPumpType"][value="${pumpType}"]`);
|
||||||
|
if (pumpRadio) {
|
||||||
|
pumpRadio.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.closeThresholdModal = function() {
|
||||||
|
const modal = document.getElementById('thresholdModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectAllThresholdPotsModal = function() {
|
||||||
|
document.querySelectorAll('.threshold-pot-checkbox').forEach((checkbox) => {
|
||||||
|
checkbox.checked = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deselectAllThresholdPotsModal = function() {
|
||||||
|
document.querySelectorAll('.threshold-pot-checkbox').forEach((checkbox) => {
|
||||||
|
checkbox.checked = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function getThresholdPayloadFromForm() {
|
||||||
|
const editThresholdKey = document.getElementById('editThresholdKey')?.value || '';
|
||||||
|
const fallbackKey = editThresholdKey || activeThresholdKey;
|
||||||
|
const plantNameInput = document.getElementById('thresholdPlantName');
|
||||||
|
const minInput = document.getElementById('thresholdMinInput');
|
||||||
|
const maxInput = document.getElementById('thresholdMaxInput');
|
||||||
|
const namaTanaman = String(plantNameInput?.value || '').trim();
|
||||||
|
|
||||||
|
if (!namaTanaman) {
|
||||||
|
showNotification('Nama tanaman untuk threshold wajib diisi', 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batasBawah = clampNumber(minInput?.value, 0, 99);
|
||||||
|
const batasAtas = clampNumber(maxInput?.value, 1, 100);
|
||||||
|
|
||||||
|
if (batasBawah >= batasAtas) {
|
||||||
|
showNotification('Batas bawah harus lebih kecil dari batas atas', 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPotValues = Array.from(document.querySelectorAll('.threshold-pot-checkbox:checked'))
|
||||||
|
.map((checkbox) => checkbox.value);
|
||||||
|
|
||||||
|
if (selectedPotValues.length === 0) {
|
||||||
|
showNotification('Pilih minimal 1 pot untuk threshold', 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPump = document.querySelector('input[name="thresholdPumpType"]:checked')?.value || 'air';
|
||||||
|
|
||||||
|
const potAktifList = Array.from(new Set(
|
||||||
|
selectedPotValues
|
||||||
|
.map((pot) => Number(String(pot).replace('pot', '')))
|
||||||
|
.filter((potNumber) => Number.isFinite(potNumber) && potNumber >= 1 && potNumber <= 5)
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
nama_tanaman: namaTanaman || getThresholdLabel(fallbackKey),
|
||||||
|
aktif: mainModeActive,
|
||||||
|
smart_mode: mainModeActive,
|
||||||
|
batas_bawah: batasBawah,
|
||||||
|
batas_atas: batasAtas,
|
||||||
|
pot_aktif: potAktifList,
|
||||||
|
pompa_air: selectedPump === 'air',
|
||||||
|
pompa_pupuk: selectedPump === 'nutrisi',
|
||||||
|
pompa_pengaduk: selectedPump === 'pengaduk'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.saveThresholdProfile = async function() {
|
||||||
|
if (!mainModeActive) {
|
||||||
|
showNotification('Aktifkan Mode Otomatis terlebih dahulu', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = getThresholdPayloadFromForm();
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editThresholdKey = document.getElementById('editThresholdKey')?.value || '';
|
||||||
|
const isEditMode = editThresholdKey !== '';
|
||||||
|
const targetKey = isEditMode ? resolveThresholdKey(editThresholdKey) : getNextThresholdKey();
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
[targetKey]: payload
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEditMode) {
|
||||||
|
updates.threshold_aktif = targetKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await update(kontrolRef, updates);
|
||||||
|
closeThresholdModal();
|
||||||
|
showNotification(
|
||||||
|
isEditMode
|
||||||
|
? `${getThresholdLabel(targetKey)} berhasil diperbarui`
|
||||||
|
: `${getThresholdLabel(targetKey)} berhasil ditambahkan`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving threshold profile:', error);
|
||||||
|
showNotification('Gagal menyimpan threshold', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteThresholdProfile(thresholdKey) {
|
||||||
|
if (!mainModeActive) {
|
||||||
|
showNotification('Aktifkan Mode Otomatis terlebih dahulu', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!thresholdProfiles.includes(thresholdKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thresholdProfiles.length <= 1) {
|
||||||
|
showNotification('Minimal harus ada 1 profil threshold', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Hapus ${getThresholdLabel(thresholdKey)}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackKey = thresholdProfiles.find((key) => key !== thresholdKey) || 'threshold_1';
|
||||||
|
const updates = {
|
||||||
|
[thresholdKey]: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeThresholdKey === thresholdKey) {
|
||||||
|
updates.threshold_aktif = fallbackKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await update(kontrolRef, updates);
|
||||||
|
showNotification(`${getThresholdLabel(thresholdKey)} berhasil dihapus`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting threshold profile:', error);
|
||||||
|
showNotification('Gagal menghapus profil threshold', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
if (!notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.className = `notification ${type}`;
|
||||||
|
|
||||||
|
const icon = notification.querySelector('.notification-icon');
|
||||||
|
if (icon) {
|
||||||
|
if (type === 'success') {
|
||||||
|
icon.className = 'notification-icon fas fa-check-circle';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
icon.className = 'notification-icon fas fa-exclamation-circle';
|
||||||
|
} else if (type === 'info') {
|
||||||
|
icon.className = 'notification-icon fas fa-info-circle';
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
icon.className = 'notification-icon fas fa-exclamation-triangle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageEl = notification.querySelector('.notification-message');
|
||||||
|
if (messageEl) {
|
||||||
|
messageEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.classList.add('show');
|
||||||
|
notification.classList.remove('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadControllerData() {
|
||||||
|
onValue(kontrolRef, (snapshot) => {
|
||||||
|
const data = snapshot.exists() ? snapshot.val() : {};
|
||||||
|
|
||||||
|
lastKontrolData = data;
|
||||||
|
thresholdProfiles = extractThresholdKeys(data);
|
||||||
|
|
||||||
|
if (data.threshold_aktif) {
|
||||||
|
activeThresholdKey = resolveThresholdKey(data.threshold_aktif);
|
||||||
|
} else {
|
||||||
|
activeThresholdKey = resolveThresholdKey(activeThresholdKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainModeActive = data.otomatis === true || data.otomatis === 1;
|
||||||
|
setMainModeVisual(mainModeActive);
|
||||||
|
updateThresholdProfileUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleMainMode = function() {
|
||||||
|
if (!isPageLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = !mainModeActive;
|
||||||
|
mainModeActive = nextState;
|
||||||
|
|
||||||
|
setMainModeVisual(mainModeActive);
|
||||||
|
updateThresholdProfileUI();
|
||||||
|
|
||||||
|
// Ketika Mode Otomatis diaktifkan, matikan Mode Waktu
|
||||||
|
// Ketika Mode Otomatis dimatikan, Mode Waktu tetap bisa digunakan
|
||||||
|
update(kontrolRef, {
|
||||||
|
otomatis: nextState,
|
||||||
|
waktu: nextState ? false : undefined,
|
||||||
|
[`${activeThresholdKey}/aktif`]: nextState,
|
||||||
|
[`${activeThresholdKey}/smart_mode`]: nextState,
|
||||||
|
threshold_aktif: activeThresholdKey
|
||||||
|
}).then(() => {
|
||||||
|
showNotification(nextState ? 'Mode Otomatis diaktifkan' : 'Mode Otomatis dinonaktifkan', nextState ? 'success' : 'info');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating mode status:', error);
|
||||||
|
mainModeActive = !nextState;
|
||||||
|
setMainModeVisual(mainModeActive);
|
||||||
|
updateThresholdProfileUI();
|
||||||
|
showNotification('Gagal menyimpan status mode', 'error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
const loginMessage = document.getElementById('loginMessage');
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('userEmail').textContent = user.email;
|
||||||
|
document.getElementById('dashboardPage').style.display = 'block';
|
||||||
|
if (loginMessage) {
|
||||||
|
loginMessage.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadControllerData();
|
||||||
|
isPageLoaded = true;
|
||||||
|
} else {
|
||||||
|
document.getElementById('dashboardPage').style.display = 'none';
|
||||||
|
if (loginMessage) {
|
||||||
|
loginMessage.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('signOutBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await signOut(auth);
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(`Error logging out: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setMainModeVisual(false);
|
||||||
|
updateThresholdProfileUI();
|
||||||
|
|
||||||
|
document.getElementById('btnAddThreshold')?.addEventListener('click', () => {
|
||||||
|
openThresholdModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('thresholdProfileContainer')?.addEventListener('click', (event) => {
|
||||||
|
const actionBtn = event.target.closest('[data-threshold-action]');
|
||||||
|
if (!actionBtn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = actionBtn.dataset.thresholdAction;
|
||||||
|
const thresholdKey = actionBtn.dataset.thresholdKey;
|
||||||
|
if (!thresholdKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'select') {
|
||||||
|
switchThresholdProfile(thresholdKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'edit') {
|
||||||
|
openThresholdModal(thresholdKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
deleteThresholdProfile(thresholdKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('thresholdModal')?.addEventListener('click', (event) => {
|
||||||
|
if (event.target.id === 'thresholdModal') {
|
||||||
|
closeThresholdModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('thresholdMinInput')?.addEventListener('change', (event) => {
|
||||||
|
const minInput = event.target;
|
||||||
|
minInput.value = clampNumber(minInput.value, 0, 99);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('thresholdMaxInput')?.addEventListener('change', (event) => {
|
||||||
|
const maxInput = event.target;
|
||||||
|
maxInput.value = clampNumber(maxInput.value, 1, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('thresholdPlantName')?.addEventListener('change', (event) => {
|
||||||
|
event.target.value = String(event.target.value || '').trim();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
// mode-waktu.js - Mode Waktu Controller with CRUD
|
||||||
|
|
||||||
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js";
|
||||||
|
import { getAuth, onAuthStateChanged, signOut } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-auth.js";
|
||||||
|
import { getDatabase, ref, set, onValue, update, remove } from "https://www.gstatic.com/firebasejs/12.7.0/firebase-database.js";
|
||||||
|
|
||||||
|
// Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyCQWvoDxDyVCuLEDiwammjUIVYxVARzJig",
|
||||||
|
authDomain: "project-ta-951b4.firebaseapp.com",
|
||||||
|
databaseURL: "https://project-ta-951b4-default-rtdb.firebaseio.com",
|
||||||
|
projectId: "project-ta-951b4",
|
||||||
|
storageBucket: "project-ta-951b4.firebasestorage.app",
|
||||||
|
messagingSenderId: "217854138058",
|
||||||
|
appId: "1:217854138058:web:50a5bcd5a61ac1820c4633",
|
||||||
|
measurementId: "G-6ML8QQEGNZ"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const database = getDatabase(app);
|
||||||
|
|
||||||
|
let schedules = [];
|
||||||
|
let isPageLoaded = false;
|
||||||
|
|
||||||
|
const MODE_WAKTU_BASE_PATH = 'kontrol_1';
|
||||||
|
|
||||||
|
function getSchedulePath(scheduleId) {
|
||||||
|
return `${MODE_WAKTU_BASE_PATH}/${scheduleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextScheduleId() {
|
||||||
|
const indices = schedules
|
||||||
|
.map((item) => {
|
||||||
|
const match = String(item.id || '').match(/^jadwal_(\d+)$/i);
|
||||||
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
|
})
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
|
||||||
|
const nextIndex = indices.length > 0 ? Math.max(...indices) + 1 : 1;
|
||||||
|
return `jadwal_${nextIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedPotsFromFirebase(potAktif) {
|
||||||
|
const selected = [];
|
||||||
|
|
||||||
|
if (Array.isArray(potAktif)) {
|
||||||
|
potAktif.forEach((value) => {
|
||||||
|
const potNumber = Number(value);
|
||||||
|
if (Number.isFinite(potNumber) && potNumber >= 1 && potNumber <= 5) {
|
||||||
|
selected.push(`pot${potNumber}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(new Set(selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (potAktif && typeof potAktif === 'object') {
|
||||||
|
const hasNumericKeys = Object.keys(potAktif).some((key) => /^\d+$/.test(key));
|
||||||
|
|
||||||
|
if (hasNumericKeys) {
|
||||||
|
Object.values(potAktif).forEach((value) => {
|
||||||
|
const potNumber = Number(value);
|
||||||
|
if (Number.isFinite(potNumber) && potNumber >= 1 && potNumber <= 5) {
|
||||||
|
selected.push(`pot${potNumber}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(new Set(selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
if (potAktif[`pot_${i}`] === true || potAktif[`pot${i}`] === true) {
|
||||||
|
selected.push(`pot${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScheduleFromMobile(id, data) {
|
||||||
|
const pots = getSelectedPotsFromFirebase(data?.pot_aktif);
|
||||||
|
|
||||||
|
let pumpType = 'air';
|
||||||
|
if (data?.pompa_pengaduk) {
|
||||||
|
pumpType = 'pengaduk';
|
||||||
|
} else if (data?.pompa_pupuk) {
|
||||||
|
pumpType = 'nutrisi';
|
||||||
|
} else if (data?.pompa_air) {
|
||||||
|
pumpType = 'air';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: data?.nama || `Jadwal ${String(id).replace('jadwal_', '')}`,
|
||||||
|
time: data?.waktu || '',
|
||||||
|
duration: Number(data?.durasi || 30),
|
||||||
|
pots,
|
||||||
|
pumpType,
|
||||||
|
aktif: data?.aktif === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMobileScheduleData(scheduleData) {
|
||||||
|
const selectedPots = Array.isArray(scheduleData.pots) ? scheduleData.pots : [];
|
||||||
|
const potAktifList = Array.from(new Set(
|
||||||
|
selectedPots
|
||||||
|
.map((pot) => Number(String(pot).replace('pot', '')))
|
||||||
|
.filter((potNumber) => Number.isFinite(potNumber) && potNumber >= 1 && potNumber <= 5)
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
aktif: true,
|
||||||
|
durasi: Number(scheduleData.duration || 30),
|
||||||
|
waktu: scheduleData.time || '',
|
||||||
|
pompa_air: scheduleData.pumpType === 'air',
|
||||||
|
pompa_pupuk: scheduleData.pumpType === 'nutrisi',
|
||||||
|
pompa_pengaduk: scheduleData.pumpType === 'pengaduk',
|
||||||
|
pot_aktif: potAktifList
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification function
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
if (!notification) {
|
||||||
|
const notif = document.createElement('div');
|
||||||
|
notif.id = 'notification';
|
||||||
|
notif.className = `notification ${type}`;
|
||||||
|
notif.textContent = message;
|
||||||
|
document.body.appendChild(notif);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notif.classList.add('show');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notif.classList.remove('show');
|
||||||
|
setTimeout(() => notif.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
notification.className = `notification ${type}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.classList.add('show');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication check
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('userEmail').textContent = user.email;
|
||||||
|
document.getElementById('dashboardPage').style.display = 'block';
|
||||||
|
// document.getElementById('loginMessage').hidden = true;
|
||||||
|
initializeMode();
|
||||||
|
} else {
|
||||||
|
document.getElementById('dashboardPage').style.display = 'none';
|
||||||
|
// document.getElementById('loginMessage').hidden = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout handler
|
||||||
|
document.getElementById('signOutBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await signOut(auth);
|
||||||
|
window.location.href = '../index.html';
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error logging out: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize boolean value from Firebase (handle string, number, boolean)
|
||||||
|
function normalizeBooleanValue(value) {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value !== 0;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const lower = value.toLowerCase().trim();
|
||||||
|
return lower === 'true' || lower === '1' || lower === 'yes';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update toggle UI based on active state
|
||||||
|
function updateToggleUI(isActive) {
|
||||||
|
const mainToggle = document.getElementById('mainToggle');
|
||||||
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const btnAddSchedule = document.getElementById('btnAddSchedule');
|
||||||
|
|
||||||
|
if (mainToggle) {
|
||||||
|
if (isActive) {
|
||||||
|
mainToggle.classList.add('active');
|
||||||
|
} else {
|
||||||
|
mainToggle.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusIndicator) {
|
||||||
|
if (isActive) {
|
||||||
|
statusIndicator.classList.add('active');
|
||||||
|
statusText.textContent = 'Aktif';
|
||||||
|
if (btnAddSchedule) btnAddSchedule.disabled = false;
|
||||||
|
} else {
|
||||||
|
statusIndicator.classList.remove('active');
|
||||||
|
statusText.textContent = 'Tidak Aktif';
|
||||||
|
if (btnAddSchedule) btnAddSchedule.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize mode
|
||||||
|
function initializeMode() {
|
||||||
|
// Listen to Mode Waktu status (field: waktu)
|
||||||
|
const statusRef = ref(database, `${MODE_WAKTU_BASE_PATH}/waktu`);
|
||||||
|
|
||||||
|
onValue(statusRef, (snapshot) => {
|
||||||
|
const firebaseValue = snapshot.val();
|
||||||
|
const isActive = normalizeBooleanValue(firebaseValue);
|
||||||
|
updateToggleUI(isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load schedules from Firebase
|
||||||
|
loadSchedulesFromFirebase();
|
||||||
|
|
||||||
|
// Setup add schedule button
|
||||||
|
setupAddScheduleButton();
|
||||||
|
|
||||||
|
isPageLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load schedules from Firebase
|
||||||
|
function loadSchedulesFromFirebase() {
|
||||||
|
const schedulesRef = ref(database, MODE_WAKTU_BASE_PATH);
|
||||||
|
|
||||||
|
onValue(schedulesRef, (snapshot) => {
|
||||||
|
schedules = [];
|
||||||
|
const data = snapshot.val();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (/^jadwal_\d+$/i.test(key) && data[key] && typeof data[key] === 'object') {
|
||||||
|
schedules.push(normalizeScheduleFromMobile(key, data[key]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
schedules.sort((a, b) => {
|
||||||
|
const aNum = parseInt(String(a.id).replace('jadwal_', ''), 10) || 0;
|
||||||
|
const bNum = parseInt(String(b.id).replace('jadwal_', ''), 10) || 0;
|
||||||
|
return aNum - bNum;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSchedules();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render schedules to the UI
|
||||||
|
function renderSchedules() {
|
||||||
|
const container = document.getElementById('scheduleContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-schedule">
|
||||||
|
<i class="fas fa-calendar-times"></i>
|
||||||
|
<p>Belum ada jadwal penyiraman</p>
|
||||||
|
<p class="empty-subtitle">Klik tombol "Tambah Jadwal" untuk membuat jadwal baru</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = schedules.map(schedule => {
|
||||||
|
const pots = schedule.pots || [];
|
||||||
|
const potList = pots.map(p => p.replace('pot', 'Pot ')).join(', ');
|
||||||
|
const pumpIcon = getPumpIcon(schedule.pumpType);
|
||||||
|
const pumpLabel = getPumpLabel(schedule.pumpType);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="schedule-item">
|
||||||
|
<div class="schedule-item-header">
|
||||||
|
<div class="schedule-name">
|
||||||
|
<i class="fas fa-calendar-check"></i>
|
||||||
|
<span>${schedule.name || 'Jadwal Penyiraman'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-actions">
|
||||||
|
<button class="btn-action btn-edit" onclick="editSchedule('${schedule.id}')" title="Edit">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-duplicate" onclick="duplicateSchedule('${schedule.id}')" title="Duplikat">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-delete" onclick="deleteSchedule('${schedule.id}')" title="Hapus">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-item-body">
|
||||||
|
<div class="schedule-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span>Waktu: ${schedule.time || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<i class="fas fa-hourglass-half"></i>
|
||||||
|
<span>Durasi: ${schedule.duration || 0} detik</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<i class="fas fa-seedling"></i>
|
||||||
|
<span>Pot: ${potList || 'Tidak ada'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<i class="${pumpIcon}"></i>
|
||||||
|
<span>${pumpLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pump icon based on type
|
||||||
|
function getPumpIcon(type) {
|
||||||
|
switch(type) {
|
||||||
|
case 'air': return 'fas fa-water';
|
||||||
|
case 'nutrisi': return 'fas fa-flask';
|
||||||
|
case 'pengaduk': return 'fas fa-blender';
|
||||||
|
default: return 'fas fa-question';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pump label based on type
|
||||||
|
function getPumpLabel(type) {
|
||||||
|
switch(type) {
|
||||||
|
case 'air': return 'Pompa Air';
|
||||||
|
case 'nutrisi': return 'Pompa Nutrisi';
|
||||||
|
case 'pengaduk': return 'Pengaduk';
|
||||||
|
default: return 'Tidak diketahui';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup add schedule button
|
||||||
|
function setupAddScheduleButton() {
|
||||||
|
document.getElementById('btnAddSchedule')?.addEventListener('click', () => {
|
||||||
|
openScheduleModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle main mode
|
||||||
|
window.toggleMainMode = function() {
|
||||||
|
if (!isPageLoaded) return;
|
||||||
|
|
||||||
|
const mainToggle = document.getElementById('mainToggle');
|
||||||
|
const currentState = mainToggle.classList.contains('active');
|
||||||
|
const newState = !currentState;
|
||||||
|
|
||||||
|
// Optimistic UI update for better UX
|
||||||
|
updateToggleUI(newState);
|
||||||
|
|
||||||
|
// Ketika Mode Waktu diaktifkan, matikan Mode Otomatis
|
||||||
|
// Ketika Mode Waktu dimatikan, Mode Otomatis tetap bisa digunakan
|
||||||
|
update(ref(database, MODE_WAKTU_BASE_PATH), {
|
||||||
|
waktu: newState,
|
||||||
|
otomatis: newState ? false : undefined
|
||||||
|
}).then(() => {
|
||||||
|
showNotification(
|
||||||
|
newState ? 'Mode Waktu diaktifkan' : 'Mode Waktu dinonaktifkan',
|
||||||
|
newState ? 'success' : 'warning'
|
||||||
|
);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error updating mode:', error);
|
||||||
|
// Revert UI on error
|
||||||
|
updateToggleUI(currentState);
|
||||||
|
showNotification('Gagal mengubah status mode', 'error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open schedule modal for add/edit
|
||||||
|
window.openScheduleModal = function(scheduleId = null) {
|
||||||
|
const modal = document.getElementById('scheduleModal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
|
||||||
|
if (scheduleId) {
|
||||||
|
// Edit mode
|
||||||
|
const schedule = schedules.find(s => s.id === scheduleId);
|
||||||
|
if (schedule) {
|
||||||
|
modalTitle.innerHTML = '<i class="fas fa-edit"></i> Edit Jadwal';
|
||||||
|
document.getElementById('editScheduleId').value = scheduleId;
|
||||||
|
document.getElementById('scheduleName').value = schedule.name || '';
|
||||||
|
document.getElementById('scheduleTime').value = schedule.time || '';
|
||||||
|
document.getElementById('scheduleDuration').value = schedule.duration || 30;
|
||||||
|
|
||||||
|
// Set pot checkboxes
|
||||||
|
const checkboxes = document.querySelectorAll('.schedule-pot-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = schedule.pots && schedule.pots.includes(cb.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set pump type
|
||||||
|
const pumpRadio = document.querySelector(`input[name="pumpType"][value="${schedule.pumpType}"]`);
|
||||||
|
if (pumpRadio) pumpRadio.checked = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add mode
|
||||||
|
modalTitle.innerHTML = '<i class="fas fa-calendar-plus"></i> Tambah Jadwal';
|
||||||
|
document.getElementById('editScheduleId').value = '';
|
||||||
|
document.getElementById('scheduleName').value = '';
|
||||||
|
document.getElementById('scheduleTime').value = '';
|
||||||
|
document.getElementById('scheduleDuration').value = 30;
|
||||||
|
|
||||||
|
// Uncheck all pots
|
||||||
|
document.querySelectorAll('.schedule-pot-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
|
||||||
|
// Reset pump type default to air when creating new schedule.
|
||||||
|
const defaultRadio = document.querySelector('input[name="pumpType"][value="air"]');
|
||||||
|
if (defaultRadio) defaultRadio.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close schedule modal
|
||||||
|
window.closeScheduleModal = function() {
|
||||||
|
document.getElementById('scheduleModal').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save schedule (add or edit)
|
||||||
|
window.saveSchedule = function() {
|
||||||
|
const scheduleId = document.getElementById('editScheduleId').value;
|
||||||
|
const name = document.getElementById('scheduleName').value.trim();
|
||||||
|
const time = document.getElementById('scheduleTime').value;
|
||||||
|
const duration = parseInt(document.getElementById('scheduleDuration').value);
|
||||||
|
|
||||||
|
// Get selected pots
|
||||||
|
const pots = Array.from(document.querySelectorAll('.schedule-pot-checkbox:checked'))
|
||||||
|
.map(cb => cb.value);
|
||||||
|
|
||||||
|
// Get pump type
|
||||||
|
const pumpType = document.querySelector('input[name="pumpType"]:checked')?.value || 'air';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name) {
|
||||||
|
alert('Nama jadwal harus diisi!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!time) {
|
||||||
|
alert('Waktu mulai harus diisi!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pots.length === 0) {
|
||||||
|
alert('Pilih minimal 1 pot!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleData = {
|
||||||
|
name,
|
||||||
|
time,
|
||||||
|
duration,
|
||||||
|
pots,
|
||||||
|
pumpType
|
||||||
|
};
|
||||||
|
|
||||||
|
const mobileScheduleData = toMobileScheduleData(scheduleData);
|
||||||
|
|
||||||
|
if (scheduleId) {
|
||||||
|
// Update existing schedule
|
||||||
|
const scheduleRef = ref(database, getSchedulePath(scheduleId));
|
||||||
|
update(scheduleRef, mobileScheduleData)
|
||||||
|
.then(() => {
|
||||||
|
closeScheduleModal();
|
||||||
|
showNotification('Jadwal berhasil diupdate!', 'success');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error updating schedule:', error);
|
||||||
|
alert('Gagal mengupdate jadwal!');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add new schedule
|
||||||
|
const newScheduleId = getNextScheduleId();
|
||||||
|
const newScheduleRef = ref(database, getSchedulePath(newScheduleId));
|
||||||
|
set(newScheduleRef, mobileScheduleData)
|
||||||
|
.then(() => {
|
||||||
|
closeScheduleModal();
|
||||||
|
showNotification('Jadwal berhasil ditambahkan!', 'success');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error adding schedule:', error);
|
||||||
|
alert('Gagal menambahkan jadwal!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit schedule
|
||||||
|
window.editSchedule = function(scheduleId) {
|
||||||
|
openScheduleModal(scheduleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duplicate schedule
|
||||||
|
window.duplicateSchedule = function(scheduleId) {
|
||||||
|
const schedule = schedules.find(s => s.id === scheduleId);
|
||||||
|
if (!schedule) return;
|
||||||
|
|
||||||
|
const newSchedule = {
|
||||||
|
name: `${schedule.name} (Copy)`,
|
||||||
|
time: schedule.time,
|
||||||
|
duration: schedule.duration,
|
||||||
|
pots: schedule.pots,
|
||||||
|
pumpType: schedule.pumpType
|
||||||
|
};
|
||||||
|
|
||||||
|
const newScheduleId = getNextScheduleId();
|
||||||
|
const newScheduleRef = ref(database, getSchedulePath(newScheduleId));
|
||||||
|
set(newScheduleRef, toMobileScheduleData(newSchedule))
|
||||||
|
.then(() => {
|
||||||
|
showNotification('Jadwal berhasil diduplikat!', 'success');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error duplicating schedule:', error);
|
||||||
|
alert('Gagal menduplikat jadwal!');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete schedule
|
||||||
|
window.deleteSchedule = function(scheduleId) {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus jadwal ini?')) return;
|
||||||
|
|
||||||
|
const scheduleRef = ref(database, getSchedulePath(scheduleId));
|
||||||
|
remove(scheduleRef)
|
||||||
|
.then(() => {
|
||||||
|
showNotification('Jadwal berhasil dihapus!', 'success');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting schedule:', error);
|
||||||
|
alert('Gagal menghapus jadwal!');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select all pots in modal
|
||||||
|
window.selectAllPotsModal = function() {
|
||||||
|
document.querySelectorAll('.schedule-pot-checkbox').forEach(cb => cb.checked = true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deselect all pots in modal
|
||||||
|
window.deselectAllPotsModal = function() {
|
||||||
|
document.querySelectorAll('.schedule-pot-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('scheduleModal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeScheduleModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Sidebar Toggle Functions
|
||||||
|
function setHamburgerState(isOpen) {
|
||||||
|
const mobileBtn = document.querySelector('.mobile-menu-btn');
|
||||||
|
if (!mobileBtn) return;
|
||||||
|
|
||||||
|
mobileBtn.classList.toggle('is-open', isOpen);
|
||||||
|
mobileBtn.setAttribute('aria-expanded', String(isOpen));
|
||||||
|
mobileBtn.setAttribute('aria-label', isOpen ? 'Tutup menu navigasi' : 'Buka menu navigasi');
|
||||||
|
|
||||||
|
const icon = mobileBtn.querySelector('i');
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
icon.classList.remove('fa-bars');
|
||||||
|
icon.classList.add('fa-xmark');
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fa-xmark');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebarOverlay');
|
||||||
|
|
||||||
|
if (sidebar) sidebar.classList.remove('mobile-open');
|
||||||
|
if (overlay) overlay.classList.remove('active');
|
||||||
|
|
||||||
|
document.body.classList.remove('sidebar-open');
|
||||||
|
setHamburgerState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebarOverlay');
|
||||||
|
if (!sidebar || !overlay) return;
|
||||||
|
|
||||||
|
const willOpen = !sidebar.classList.contains('mobile-open');
|
||||||
|
sidebar.classList.toggle('mobile-open', willOpen);
|
||||||
|
overlay.classList.toggle('active', willOpen);
|
||||||
|
document.body.classList.toggle('sidebar-open', willOpen);
|
||||||
|
setHamburgerState(willOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebarCollapse() {
|
||||||
|
if (window.innerWidth <= 768) return;
|
||||||
|
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
const icon = document.querySelector('.sidebar-toggle i');
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
if (sidebar.classList.contains('collapsed')) {
|
||||||
|
icon.classList.remove('fa-chevron-left');
|
||||||
|
icon.classList.add('fa-chevron-right');
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fa-chevron-right');
|
||||||
|
icon.classList.add('fa-chevron-left');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current time
|
||||||
|
function updateTime() {
|
||||||
|
const timeElement = document.getElementById('currentTime');
|
||||||
|
if (!timeElement) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let options;
|
||||||
|
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
options = {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
options = {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
timeElement.textContent = now.toLocaleString('id-ID', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
if (window.innerWidth > 768) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sidebar functionality
|
||||||
|
function initializeSidebar() {
|
||||||
|
updateTime();
|
||||||
|
setInterval(updateTime, 60000);
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
setHamburgerState(false);
|
||||||
|
|
||||||
|
document.querySelectorAll('.sidebar .menu-item').forEach((menuItem) => {
|
||||||
|
menuItem.addEventListener('click', () => {
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeSidebar);
|
||||||
|
} else {
|
||||||
|
initializeSidebar();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
// ==========================================
|
||||||
|
// THEME TOGGLE FUNCTIONALITY
|
||||||
|
// Light Mode / Dark Mode with localStorage
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Get saved theme from localStorage or default to 'light-mode'
|
||||||
|
function getSavedTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('smartpot-theme');
|
||||||
|
return savedTheme || 'light-mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme to body
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.body.className = theme;
|
||||||
|
|
||||||
|
// Update icon
|
||||||
|
const themeIcon = document.getElementById('themeIcon');
|
||||||
|
if (themeIcon) {
|
||||||
|
if (theme === 'dark-mode') {
|
||||||
|
themeIcon.className = 'fas fa-moon';
|
||||||
|
} else {
|
||||||
|
themeIcon.className = 'fas fa-sun';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('smartpot-theme', theme);
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
console.log('Theme applied:', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle between light and dark mode
|
||||||
|
function toggleTheme() {
|
||||||
|
const currentTheme = document.body.className || 'light-mode';
|
||||||
|
const newTheme = currentTheme === 'light-mode' ? 'dark-mode' : 'light-mode';
|
||||||
|
|
||||||
|
// Add animation class to icon
|
||||||
|
const themeToggleBtn = document.getElementById('themeToggleBtn');
|
||||||
|
if (themeToggleBtn) {
|
||||||
|
themeToggleBtn.classList.add('theme-toggle-animate');
|
||||||
|
setTimeout(() => {
|
||||||
|
themeToggleBtn.classList.remove('theme-toggle-animate');
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply new theme
|
||||||
|
applyTheme(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize theme on page load
|
||||||
|
function initTheme() {
|
||||||
|
const savedTheme = getSavedTheme();
|
||||||
|
applyTheme(savedTheme);
|
||||||
|
console.log('Theme initialized:', savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on DOM content loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', initTheme);
|
||||||
|
|
||||||
|
// Export functions for use in HTML
|
||||||
|
window.toggleTheme = toggleTheme;
|
||||||
|
window.initTheme = initTheme;
|
||||||
|
|
@ -0,0 +1,614 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
|
||||||
|
|
||||||
|
var throttle = _interopDefault(require('lodash.throttle'));
|
||||||
|
var debounce = _interopDefault(require('lodash.debounce'));
|
||||||
|
|
||||||
|
var callback = function callback() {};
|
||||||
|
|
||||||
|
function containsAOSNode(nodes) {
|
||||||
|
var i = void 0,
|
||||||
|
currentNode = void 0,
|
||||||
|
result = void 0;
|
||||||
|
|
||||||
|
for (i = 0; i < nodes.length; i += 1) {
|
||||||
|
currentNode = nodes[i];
|
||||||
|
|
||||||
|
if (currentNode.dataset && currentNode.dataset.aos) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = currentNode.children && containsAOSNode(currentNode.children);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(mutations) {
|
||||||
|
if (!mutations) return;
|
||||||
|
|
||||||
|
mutations.forEach(function (mutation) {
|
||||||
|
var addedNodes = Array.prototype.slice.call(mutation.addedNodes);
|
||||||
|
var removedNodes = Array.prototype.slice.call(mutation.removedNodes);
|
||||||
|
var allNodes = addedNodes.concat(removedNodes);
|
||||||
|
|
||||||
|
if (containsAOSNode(allNodes)) {
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMutationObserver() {
|
||||||
|
return window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupported() {
|
||||||
|
return !!getMutationObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(selector, fn) {
|
||||||
|
var doc = window.document;
|
||||||
|
var MutationObserver = getMutationObserver();
|
||||||
|
|
||||||
|
var observer = new MutationObserver(check);
|
||||||
|
callback = fn;
|
||||||
|
|
||||||
|
observer.observe(doc.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
removedNodes: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var observer = { isSupported: isSupported, ready: ready };
|
||||||
|
|
||||||
|
var classCallCheck = function (instance, Constructor) {
|
||||||
|
if (!(instance instanceof Constructor)) {
|
||||||
|
throw new TypeError("Cannot call a class as a function");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createClass = function () {
|
||||||
|
function defineProperties(target, props) {
|
||||||
|
for (var i = 0; i < props.length; i++) {
|
||||||
|
var descriptor = props[i];
|
||||||
|
descriptor.enumerable = descriptor.enumerable || false;
|
||||||
|
descriptor.configurable = true;
|
||||||
|
if ("value" in descriptor) descriptor.writable = true;
|
||||||
|
Object.defineProperty(target, descriptor.key, descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (Constructor, protoProps, staticProps) {
|
||||||
|
if (protoProps) defineProperties(Constructor.prototype, protoProps);
|
||||||
|
if (staticProps) defineProperties(Constructor, staticProps);
|
||||||
|
return Constructor;
|
||||||
|
};
|
||||||
|
}();
|
||||||
|
|
||||||
|
var _extends = Object.assign || function (target) {
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
var source = arguments[i];
|
||||||
|
|
||||||
|
for (var key in source) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device detector
|
||||||
|
*/
|
||||||
|
|
||||||
|
var fullNameRe = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i;
|
||||||
|
var prefixRe = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i;
|
||||||
|
var fullNameMobileRe = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i;
|
||||||
|
var prefixMobileRe = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i;
|
||||||
|
|
||||||
|
function ua() {
|
||||||
|
return navigator.userAgent || navigator.vendor || window.opera || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var Detector = function () {
|
||||||
|
function Detector() {
|
||||||
|
classCallCheck(this, Detector);
|
||||||
|
}
|
||||||
|
|
||||||
|
createClass(Detector, [{
|
||||||
|
key: 'phone',
|
||||||
|
value: function phone() {
|
||||||
|
var a = ua();
|
||||||
|
return !!(fullNameRe.test(a) || prefixRe.test(a.substr(0, 4)));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: 'mobile',
|
||||||
|
value: function mobile() {
|
||||||
|
var a = ua();
|
||||||
|
return !!(fullNameMobileRe.test(a) || prefixMobileRe.test(a.substr(0, 4)));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: 'tablet',
|
||||||
|
value: function tablet() {
|
||||||
|
return this.mobile() && !this.phone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// http://browserhacks.com/#hack-acea075d0ac6954f275a70023906050c
|
||||||
|
|
||||||
|
}, {
|
||||||
|
key: 'ie11',
|
||||||
|
value: function ie11() {
|
||||||
|
return '-ms-scroll-limit' in document.documentElement.style && '-ms-ime-align' in document.documentElement.style;
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
return Detector;
|
||||||
|
}();
|
||||||
|
|
||||||
|
var detect = new Detector();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple classes on node
|
||||||
|
* @param {DOMNode} node
|
||||||
|
* @param {array} classes
|
||||||
|
*/
|
||||||
|
var addClasses = function addClasses(node, classes) {
|
||||||
|
return classes && classes.forEach(function (className) {
|
||||||
|
return node.classList.add(className);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes multiple classes from node
|
||||||
|
* @param {DOMNode} node
|
||||||
|
* @param {array} classes
|
||||||
|
*/
|
||||||
|
var removeClasses = function removeClasses(node, classes) {
|
||||||
|
return classes && classes.forEach(function (className) {
|
||||||
|
return node.classList.remove(className);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var fireEvent = function fireEvent(eventName, data) {
|
||||||
|
var customEvent = void 0;
|
||||||
|
|
||||||
|
if (detect.ie11()) {
|
||||||
|
customEvent = document.createEvent('CustomEvent');
|
||||||
|
customEvent.initCustomEvent(eventName, true, true, { detail: data });
|
||||||
|
} else {
|
||||||
|
customEvent = new CustomEvent(eventName, {
|
||||||
|
detail: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.dispatchEvent(customEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or remove aos-animate class
|
||||||
|
* @param {node} el element
|
||||||
|
* @param {int} top scrolled distance
|
||||||
|
*/
|
||||||
|
var applyClasses = function applyClasses(el, top) {
|
||||||
|
var options = el.options,
|
||||||
|
position = el.position,
|
||||||
|
node = el.node,
|
||||||
|
data = el.data;
|
||||||
|
|
||||||
|
|
||||||
|
var hide = function hide() {
|
||||||
|
if (!el.animated) return;
|
||||||
|
|
||||||
|
removeClasses(node, options.animatedClassNames);
|
||||||
|
fireEvent('aos:out', node);
|
||||||
|
|
||||||
|
if (el.options.id) {
|
||||||
|
fireEvent('aos:in:' + el.options.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.animated = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
var show = function show() {
|
||||||
|
if (el.animated) return;
|
||||||
|
|
||||||
|
addClasses(node, options.animatedClassNames);
|
||||||
|
|
||||||
|
fireEvent('aos:in', node);
|
||||||
|
if (el.options.id) {
|
||||||
|
fireEvent('aos:in:' + el.options.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.animated = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.mirror && top >= position.out && !options.once) {
|
||||||
|
hide();
|
||||||
|
} else if (top >= position.in) {
|
||||||
|
show();
|
||||||
|
} else if (el.animated && !options.once) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll logic - add or remove 'aos-animate' class on scroll
|
||||||
|
*
|
||||||
|
* @param {array} $elements array of elements nodes
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
var handleScroll = function handleScroll($elements) {
|
||||||
|
return $elements.forEach(function (el, i) {
|
||||||
|
return applyClasses(el, window.pageYOffset);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get offset of DOM element
|
||||||
|
* like there were no transforms applied on it
|
||||||
|
*
|
||||||
|
* @param {Node} el [DOM element]
|
||||||
|
* @return {Object} [top and left offset]
|
||||||
|
*/
|
||||||
|
var offset = function offset(el) {
|
||||||
|
var _x = 0;
|
||||||
|
var _y = 0;
|
||||||
|
|
||||||
|
while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) {
|
||||||
|
_x += el.offsetLeft - (el.tagName != 'BODY' ? el.scrollLeft : 0);
|
||||||
|
_y += el.offsetTop - (el.tagName != 'BODY' ? el.scrollTop : 0);
|
||||||
|
el = el.offsetParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: _y,
|
||||||
|
left: _x
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inline option with a fallback.
|
||||||
|
*
|
||||||
|
* @param {Node} el [Dom element]
|
||||||
|
* @param {String} key [Option key]
|
||||||
|
* @param {String} fallback [Default (fallback) value]
|
||||||
|
* @return {Mixed} [Option set with inline attributes or fallback value if not set]
|
||||||
|
*/
|
||||||
|
|
||||||
|
var getInlineOption = (function (el, key, fallback) {
|
||||||
|
var attr = el.getAttribute('data-aos-' + key);
|
||||||
|
|
||||||
|
if (typeof attr !== 'undefined') {
|
||||||
|
if (attr === 'true') {
|
||||||
|
return true;
|
||||||
|
} else if (attr === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attr || fallback;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate offset
|
||||||
|
* basing on element's settings like:
|
||||||
|
* - anchor
|
||||||
|
* - offset
|
||||||
|
*
|
||||||
|
* @param {Node} el [Dom element]
|
||||||
|
* @return {Integer} [Final offset that will be used to trigger animation in good position]
|
||||||
|
*/
|
||||||
|
|
||||||
|
var getPositionIn = function getPositionIn(el, defaultOffset, defaultAnchorPlacement) {
|
||||||
|
var windowHeight = window.innerHeight;
|
||||||
|
var anchor = getInlineOption(el, 'anchor');
|
||||||
|
var inlineAnchorPlacement = getInlineOption(el, 'anchor-placement');
|
||||||
|
var additionalOffset = Number(getInlineOption(el, 'offset', inlineAnchorPlacement ? 0 : defaultOffset));
|
||||||
|
var anchorPlacement = inlineAnchorPlacement || defaultAnchorPlacement;
|
||||||
|
var finalEl = el;
|
||||||
|
|
||||||
|
if (anchor && document.querySelectorAll(anchor)) {
|
||||||
|
finalEl = document.querySelectorAll(anchor)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggerPoint = offset(finalEl).top - windowHeight;
|
||||||
|
|
||||||
|
switch (anchorPlacement) {
|
||||||
|
case 'top-bottom':
|
||||||
|
// Default offset
|
||||||
|
break;
|
||||||
|
case 'center-bottom':
|
||||||
|
triggerPoint += finalEl.offsetHeight / 2;
|
||||||
|
break;
|
||||||
|
case 'bottom-bottom':
|
||||||
|
triggerPoint += finalEl.offsetHeight;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
triggerPoint += windowHeight / 2;
|
||||||
|
break;
|
||||||
|
case 'center-center':
|
||||||
|
triggerPoint += windowHeight / 2 + finalEl.offsetHeight / 2;
|
||||||
|
break;
|
||||||
|
case 'bottom-center':
|
||||||
|
triggerPoint += windowHeight / 2 + finalEl.offsetHeight;
|
||||||
|
break;
|
||||||
|
case 'top-top':
|
||||||
|
triggerPoint += windowHeight;
|
||||||
|
break;
|
||||||
|
case 'bottom-top':
|
||||||
|
triggerPoint += windowHeight + finalEl.offsetHeight;
|
||||||
|
break;
|
||||||
|
case 'center-top':
|
||||||
|
triggerPoint += windowHeight + finalEl.offsetHeight / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return triggerPoint + additionalOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getPositionOut = function getPositionOut(el, defaultOffset) {
|
||||||
|
var windowHeight = window.innerHeight;
|
||||||
|
var anchor = getInlineOption(el, 'anchor');
|
||||||
|
var additionalOffset = getInlineOption(el, 'offset', defaultOffset);
|
||||||
|
var finalEl = el;
|
||||||
|
|
||||||
|
if (anchor && document.querySelectorAll(anchor)) {
|
||||||
|
finalEl = document.querySelectorAll(anchor)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var elementOffsetTop = offset(finalEl).top;
|
||||||
|
|
||||||
|
return elementOffsetTop + finalEl.offsetHeight - additionalOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Clearing variables */
|
||||||
|
|
||||||
|
var prepare = function prepare($elements, options) {
|
||||||
|
$elements.forEach(function (el, i) {
|
||||||
|
var mirror = getInlineOption(el.node, 'mirror', options.mirror);
|
||||||
|
var once = getInlineOption(el.node, 'once', options.once);
|
||||||
|
var id = getInlineOption(el.node, 'id');
|
||||||
|
var customClassNames = options.useClassNames && el.node.getAttribute('data-aos');
|
||||||
|
|
||||||
|
var animatedClassNames = [options.animatedClassName].concat(customClassNames ? customClassNames.split(' ') : []).filter(function (className) {
|
||||||
|
return typeof className === 'string';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.initClassName) {
|
||||||
|
el.node.classList.add(options.initClassName);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.position = {
|
||||||
|
in: getPositionIn(el.node, options.offset, options.anchorPlacement),
|
||||||
|
out: mirror && getPositionOut(el.node, options.offset)
|
||||||
|
};
|
||||||
|
|
||||||
|
el.options = {
|
||||||
|
once: once,
|
||||||
|
mirror: mirror,
|
||||||
|
animatedClassNames: animatedClassNames,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return $elements;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate initial array with elements as objects
|
||||||
|
* This array will be extended later with elements attributes values
|
||||||
|
* like 'position'
|
||||||
|
*/
|
||||||
|
var elements = (function () {
|
||||||
|
var elements = document.querySelectorAll('[data-aos]');
|
||||||
|
return Array.prototype.map.call(elements, function (node) {
|
||||||
|
return { node: node };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* *******************************************************
|
||||||
|
* AOS (Animate on scroll) - wowjs alternative
|
||||||
|
* made to animate elements on scroll in both directions
|
||||||
|
* *******************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private variables
|
||||||
|
*/
|
||||||
|
var $aosElements = [];
|
||||||
|
var initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default options
|
||||||
|
*/
|
||||||
|
var options = {
|
||||||
|
offset: 120,
|
||||||
|
delay: 0,
|
||||||
|
easing: 'ease',
|
||||||
|
duration: 400,
|
||||||
|
disable: false,
|
||||||
|
once: false,
|
||||||
|
mirror: false,
|
||||||
|
anchorPlacement: 'top-bottom',
|
||||||
|
startEvent: 'DOMContentLoaded',
|
||||||
|
animatedClassName: 'aos-animate',
|
||||||
|
initClassName: 'aos-init',
|
||||||
|
useClassNames: false,
|
||||||
|
disableMutationObserver: false,
|
||||||
|
throttleDelay: 99,
|
||||||
|
debounceDelay: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect not supported browsers (<=IE9)
|
||||||
|
// http://browserhacks.com/#hack-e71d8692f65334173fee715c222cb805
|
||||||
|
var isBrowserNotSupported = function isBrowserNotSupported() {
|
||||||
|
return document.all && !window.atob;
|
||||||
|
};
|
||||||
|
|
||||||
|
var initializeScroll = function initializeScroll() {
|
||||||
|
// Extend elements objects in $aosElements with their positions
|
||||||
|
$aosElements = prepare($aosElements, options);
|
||||||
|
// Perform scroll event, to refresh view and show/hide elements
|
||||||
|
handleScroll($aosElements);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll event to animate elements on scroll
|
||||||
|
*/
|
||||||
|
window.addEventListener('scroll', throttle(function () {
|
||||||
|
handleScroll($aosElements, options.once);
|
||||||
|
}, options.throttleDelay));
|
||||||
|
|
||||||
|
return $aosElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh AOS
|
||||||
|
*/
|
||||||
|
var refresh = function refresh() {
|
||||||
|
var initialize = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
||||||
|
|
||||||
|
// Allow refresh only when it was first initialized on startEvent
|
||||||
|
if (initialize) initialized = true;
|
||||||
|
if (initialized) initializeScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard refresh
|
||||||
|
* create array with new elements and trigger refresh
|
||||||
|
*/
|
||||||
|
var refreshHard = function refreshHard() {
|
||||||
|
$aosElements = elements();
|
||||||
|
|
||||||
|
if (isDisabled(options.disable) || isBrowserNotSupported()) {
|
||||||
|
return disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable AOS
|
||||||
|
* Remove all attributes to reset applied styles
|
||||||
|
*/
|
||||||
|
var disable = function disable() {
|
||||||
|
$aosElements.forEach(function (el, i) {
|
||||||
|
el.node.removeAttribute('data-aos');
|
||||||
|
el.node.removeAttribute('data-aos-easing');
|
||||||
|
el.node.removeAttribute('data-aos-duration');
|
||||||
|
el.node.removeAttribute('data-aos-delay');
|
||||||
|
|
||||||
|
if (options.initClassName) {
|
||||||
|
el.node.classList.remove(options.initClassName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.animatedClassName) {
|
||||||
|
el.node.classList.remove(options.animatedClassName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if AOS should be disabled based on provided setting
|
||||||
|
*/
|
||||||
|
var isDisabled = function isDisabled(optionDisable) {
|
||||||
|
return optionDisable === true || optionDisable === 'mobile' && detect.mobile() || optionDisable === 'phone' && detect.phone() || optionDisable === 'tablet' && detect.tablet() || typeof optionDisable === 'function' && optionDisable() === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializing AOS
|
||||||
|
* - Create options merging defaults with user defined options
|
||||||
|
* - Set attributes on <body> as global setting - css relies on it
|
||||||
|
* - Attach preparing elements to options.startEvent,
|
||||||
|
* window resize and orientation change
|
||||||
|
* - Attach function that handle scroll and everything connected to it
|
||||||
|
* to window scroll event and fire once document is ready to set initial state
|
||||||
|
*/
|
||||||
|
var init = function init(settings) {
|
||||||
|
options = _extends(options, settings);
|
||||||
|
|
||||||
|
// Create initial array with elements -> to be fullfilled later with prepare()
|
||||||
|
$aosElements = elements();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable mutation observing if not supported
|
||||||
|
*/
|
||||||
|
if (!options.disableMutationObserver && !observer.isSupported()) {
|
||||||
|
console.info('\n aos: MutationObserver is not supported on this browser,\n code mutations observing has been disabled.\n You may have to call "refreshHard()" by yourself.\n ');
|
||||||
|
options.disableMutationObserver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe [aos] elements
|
||||||
|
* If something is loaded by AJAX
|
||||||
|
* it'll refresh plugin automatically
|
||||||
|
*/
|
||||||
|
if (!options.disableMutationObserver) {
|
||||||
|
observer.ready('[data-aos]', refreshHard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't init plugin if option `disable` is set
|
||||||
|
* or when browser is not supported
|
||||||
|
*/
|
||||||
|
if (isDisabled(options.disable) || isBrowserNotSupported()) {
|
||||||
|
return disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set global settings on body, based on options
|
||||||
|
* so CSS can use it
|
||||||
|
*/
|
||||||
|
document.querySelector('body').setAttribute('data-aos-easing', options.easing);
|
||||||
|
|
||||||
|
document.querySelector('body').setAttribute('data-aos-duration', options.duration);
|
||||||
|
|
||||||
|
document.querySelector('body').setAttribute('data-aos-delay', options.delay);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initializing
|
||||||
|
*/
|
||||||
|
if (['DOMContentLoaded', 'load'].indexOf(options.startEvent) === -1) {
|
||||||
|
// Listen to options.startEvent and initialize AOS
|
||||||
|
document.addEventListener(options.startEvent, function () {
|
||||||
|
refresh(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
refresh(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.startEvent === 'DOMContentLoaded' && ['complete', 'interactive'].indexOf(document.readyState) > -1) {
|
||||||
|
// Initialize AOS if default startEvent was already fired
|
||||||
|
refresh(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh plugin on window resize or orientation change
|
||||||
|
*/
|
||||||
|
window.addEventListener('resize', debounce(refresh, options.debounceDelay, true));
|
||||||
|
|
||||||
|
window.addEventListener('orientationchange', debounce(refresh, options.debounceDelay, true));
|
||||||
|
|
||||||
|
return $aosElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
var aos = {
|
||||||
|
init: init,
|
||||||
|
refresh: refresh,
|
||||||
|
refreshHard: refreshHard
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = aos;
|
||||||
|
|
@ -0,0 +1,610 @@
|
||||||
|
import throttle from 'lodash.throttle';
|
||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
|
||||||
|
var callback = function callback() {};
|
||||||
|
|
||||||
|
function containsAOSNode(nodes) {
|
||||||
|
var i = void 0,
|
||||||
|
currentNode = void 0,
|
||||||
|
result = void 0;
|
||||||
|
|
||||||
|
for (i = 0; i < nodes.length; i += 1) {
|
||||||
|
currentNode = nodes[i];
|
||||||
|
|
||||||
|
if (currentNode.dataset && currentNode.dataset.aos) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = currentNode.children && containsAOSNode(currentNode.children);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(mutations) {
|
||||||
|
if (!mutations) return;
|
||||||
|
|
||||||
|
mutations.forEach(function (mutation) {
|
||||||
|
var addedNodes = Array.prototype.slice.call(mutation.addedNodes);
|
||||||
|
var removedNodes = Array.prototype.slice.call(mutation.removedNodes);
|
||||||
|
var allNodes = addedNodes.concat(removedNodes);
|
||||||
|
|
||||||
|
if (containsAOSNode(allNodes)) {
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMutationObserver() {
|
||||||
|
return window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupported() {
|
||||||
|
return !!getMutationObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(selector, fn) {
|
||||||
|
var doc = window.document;
|
||||||
|
var MutationObserver = getMutationObserver();
|
||||||
|
|
||||||
|
var observer = new MutationObserver(check);
|
||||||
|
callback = fn;
|
||||||
|
|
||||||
|
observer.observe(doc.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
removedNodes: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var observer = { isSupported: isSupported, ready: ready };
|
||||||
|
|
||||||
|
var classCallCheck = function (instance, Constructor) {
|
||||||
|
if (!(instance instanceof Constructor)) {
|
||||||
|
throw new TypeError("Cannot call a class as a function");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createClass = function () {
|
||||||
|
function defineProperties(target, props) {
|
||||||
|
for (var i = 0; i < props.length; i++) {
|
||||||
|
var descriptor = props[i];
|
||||||
|
descriptor.enumerable = descriptor.enumerable || false;
|
||||||
|
descriptor.configurable = true;
|
||||||
|
if ("value" in descriptor) descriptor.writable = true;
|
||||||
|
Object.defineProperty(target, descriptor.key, descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (Constructor, protoProps, staticProps) {
|
||||||
|
if (protoProps) defineProperties(Constructor.prototype, protoProps);
|
||||||
|
if (staticProps) defineProperties(Constructor, staticProps);
|
||||||
|
return Constructor;
|
||||||
|
};
|
||||||
|
}();
|
||||||
|
|
||||||
|
var _extends = Object.assign || function (target) {
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
var source = arguments[i];
|
||||||
|
|
||||||
|
for (var key in source) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device detector
|
||||||
|
*/
|
||||||
|
|
||||||
|
var fullNameRe = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i;
|
||||||
|
var prefixRe = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i;
|
||||||
|
var fullNameMobileRe = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i;
|
||||||
|
var prefixMobileRe = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i;
|
||||||
|
|
||||||
|
function ua() {
|
||||||
|
return navigator.userAgent || navigator.vendor || window.opera || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var Detector = function () {
|
||||||
|
function Detector() {
|
||||||
|
classCallCheck(this, Detector);
|
||||||
|
}
|
||||||
|
|
||||||
|
createClass(Detector, [{
|
||||||
|
key: 'phone',
|
||||||
|
value: function phone() {
|
||||||
|
var a = ua();
|
||||||
|
return !!(fullNameRe.test(a) || prefixRe.test(a.substr(0, 4)));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: 'mobile',
|
||||||
|
value: function mobile() {
|
||||||
|
var a = ua();
|
||||||
|
return !!(fullNameMobileRe.test(a) || prefixMobileRe.test(a.substr(0, 4)));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: 'tablet',
|
||||||
|
value: function tablet() {
|
||||||
|
return this.mobile() && !this.phone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// http://browserhacks.com/#hack-acea075d0ac6954f275a70023906050c
|
||||||
|
|
||||||
|
}, {
|
||||||
|
key: 'ie11',
|
||||||
|
value: function ie11() {
|
||||||
|
return '-ms-scroll-limit' in document.documentElement.style && '-ms-ime-align' in document.documentElement.style;
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
return Detector;
|
||||||
|
}();
|
||||||
|
|
||||||
|
var detect = new Detector();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple classes on node
|
||||||
|
* @param {DOMNode} node
|
||||||
|
* @param {array} classes
|
||||||
|
*/
|
||||||
|
var addClasses = function addClasses(node, classes) {
|
||||||
|
return classes && classes.forEach(function (className) {
|
||||||
|
return node.classList.add(className);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes multiple classes from node
|
||||||
|
* @param {DOMNode} node
|
||||||
|
* @param {array} classes
|
||||||
|
*/
|
||||||
|
var removeClasses = function removeClasses(node, classes) {
|
||||||
|
return classes && classes.forEach(function (className) {
|
||||||
|
return node.classList.remove(className);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var fireEvent = function fireEvent(eventName, data) {
|
||||||
|
var customEvent = void 0;
|
||||||
|
|
||||||
|
if (detect.ie11()) {
|
||||||
|
customEvent = document.createEvent('CustomEvent');
|
||||||
|
customEvent.initCustomEvent(eventName, true, true, { detail: data });
|
||||||
|
} else {
|
||||||
|
customEvent = new CustomEvent(eventName, {
|
||||||
|
detail: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.dispatchEvent(customEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or remove aos-animate class
|
||||||
|
* @param {node} el element
|
||||||
|
* @param {int} top scrolled distance
|
||||||
|
*/
|
||||||
|
var applyClasses = function applyClasses(el, top) {
|
||||||
|
var options = el.options,
|
||||||
|
position = el.position,
|
||||||
|
node = el.node,
|
||||||
|
data = el.data;
|
||||||
|
|
||||||
|
|
||||||
|
var hide = function hide() {
|
||||||
|
if (!el.animated) return;
|
||||||
|
|
||||||
|
removeClasses(node, options.animatedClassNames);
|
||||||
|
fireEvent('aos:out', node);
|
||||||
|
|
||||||
|
if (el.options.id) {
|
||||||
|
fireEvent('aos:in:' + el.options.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.animated = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
var show = function show() {
|
||||||
|
if (el.animated) return;
|
||||||
|
|
||||||
|
addClasses(node, options.animatedClassNames);
|
||||||
|
|
||||||
|
fireEvent('aos:in', node);
|
||||||
|
if (el.options.id) {
|
||||||
|
fireEvent('aos:in:' + el.options.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.animated = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.mirror && top >= position.out && !options.once) {
|
||||||
|
hide();
|
||||||
|
} else if (top >= position.in) {
|
||||||
|
show();
|
||||||
|
} else if (el.animated && !options.once) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll logic - add or remove 'aos-animate' class on scroll
|
||||||
|
*
|
||||||
|
* @param {array} $elements array of elements nodes
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
var handleScroll = function handleScroll($elements) {
|
||||||
|
return $elements.forEach(function (el, i) {
|
||||||
|
return applyClasses(el, window.pageYOffset);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get offset of DOM element
|
||||||
|
* like there were no transforms applied on it
|
||||||
|
*
|
||||||
|
* @param {Node} el [DOM element]
|
||||||
|
* @return {Object} [top and left offset]
|
||||||
|
*/
|
||||||
|
var offset = function offset(el) {
|
||||||
|
var _x = 0;
|
||||||
|
var _y = 0;
|
||||||
|
|
||||||
|
while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) {
|
||||||
|
_x += el.offsetLeft - (el.tagName != 'BODY' ? el.scrollLeft : 0);
|
||||||
|
_y += el.offsetTop - (el.tagName != 'BODY' ? el.scrollTop : 0);
|
||||||
|
el = el.offsetParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: _y,
|
||||||
|
left: _x
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inline option with a fallback.
|
||||||
|
*
|
||||||
|
* @param {Node} el [Dom element]
|
||||||
|
* @param {String} key [Option key]
|
||||||
|
* @param {String} fallback [Default (fallback) value]
|
||||||
|
* @return {Mixed} [Option set with inline attributes or fallback value if not set]
|
||||||
|
*/
|
||||||
|
|
||||||
|
var getInlineOption = (function (el, key, fallback) {
|
||||||
|
var attr = el.getAttribute('data-aos-' + key);
|
||||||
|
|
||||||
|
if (typeof attr !== 'undefined') {
|
||||||
|
if (attr === 'true') {
|
||||||
|
return true;
|
||||||
|
} else if (attr === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attr || fallback;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate offset
|
||||||
|
* basing on element's settings like:
|
||||||
|
* - anchor
|
||||||
|
* - offset
|
||||||
|
*
|
||||||
|
* @param {Node} el [Dom element]
|
||||||
|
* @return {Integer} [Final offset that will be used to trigger animation in good position]
|
||||||
|
*/
|
||||||
|
|
||||||
|
var getPositionIn = function getPositionIn(el, defaultOffset, defaultAnchorPlacement) {
|
||||||
|
var windowHeight = window.innerHeight;
|
||||||
|
var anchor = getInlineOption(el, 'anchor');
|
||||||
|
var inlineAnchorPlacement = getInlineOption(el, 'anchor-placement');
|
||||||
|
var additionalOffset = Number(getInlineOption(el, 'offset', inlineAnchorPlacement ? 0 : defaultOffset));
|
||||||
|
var anchorPlacement = inlineAnchorPlacement || defaultAnchorPlacement;
|
||||||
|
var finalEl = el;
|
||||||
|
|
||||||
|
if (anchor && document.querySelectorAll(anchor)) {
|
||||||
|
finalEl = document.querySelectorAll(anchor)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggerPoint = offset(finalEl).top - windowHeight;
|
||||||
|
|
||||||
|
switch (anchorPlacement) {
|
||||||
|
case 'top-bottom':
|
||||||
|
// Default offset
|
||||||
|
break;
|
||||||
|
case 'center-bottom':
|
||||||
|
triggerPoint += finalEl.offsetHeight / 2;
|
||||||
|
break;
|
||||||
|
case 'bottom-bottom':
|
||||||
|
triggerPoint += finalEl.offsetHeight;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
triggerPoint += windowHeight / 2;
|
||||||
|
break;
|
||||||
|
case 'center-center':
|
||||||
|
triggerPoint += windowHeight / 2 + finalEl.offsetHeight / 2;
|
||||||
|
break;
|
||||||
|
case 'bottom-center':
|
||||||
|
triggerPoint += windowHeight / 2 + finalEl.offsetHeight;
|
||||||
|
break;
|
||||||
|
case 'top-top':
|
||||||
|
triggerPoint += windowHeight;
|
||||||
|
break;
|
||||||
|
case 'bottom-top':
|
||||||
|
triggerPoint += windowHeight + finalEl.offsetHeight;
|
||||||
|
break;
|
||||||
|
case 'center-top':
|
||||||
|
triggerPoint += windowHeight + finalEl.offsetHeight / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return triggerPoint + additionalOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getPositionOut = function getPositionOut(el, defaultOffset) {
|
||||||
|
var windowHeight = window.innerHeight;
|
||||||
|
var anchor = getInlineOption(el, 'anchor');
|
||||||
|
var additionalOffset = getInlineOption(el, 'offset', defaultOffset);
|
||||||
|
var finalEl = el;
|
||||||
|
|
||||||
|
if (anchor && document.querySelectorAll(anchor)) {
|
||||||
|
finalEl = document.querySelectorAll(anchor)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var elementOffsetTop = offset(finalEl).top;
|
||||||
|
|
||||||
|
return elementOffsetTop + finalEl.offsetHeight - additionalOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Clearing variables */
|
||||||
|
|
||||||
|
var prepare = function prepare($elements, options) {
|
||||||
|
$elements.forEach(function (el, i) {
|
||||||
|
var mirror = getInlineOption(el.node, 'mirror', options.mirror);
|
||||||
|
var once = getInlineOption(el.node, 'once', options.once);
|
||||||
|
var id = getInlineOption(el.node, 'id');
|
||||||
|
var customClassNames = options.useClassNames && el.node.getAttribute('data-aos');
|
||||||
|
|
||||||
|
var animatedClassNames = [options.animatedClassName].concat(customClassNames ? customClassNames.split(' ') : []).filter(function (className) {
|
||||||
|
return typeof className === 'string';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.initClassName) {
|
||||||
|
el.node.classList.add(options.initClassName);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.position = {
|
||||||
|
in: getPositionIn(el.node, options.offset, options.anchorPlacement),
|
||||||
|
out: mirror && getPositionOut(el.node, options.offset)
|
||||||
|
};
|
||||||
|
|
||||||
|
el.options = {
|
||||||
|
once: once,
|
||||||
|
mirror: mirror,
|
||||||
|
animatedClassNames: animatedClassNames,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return $elements;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate initial array with elements as objects
|
||||||
|
* This array will be extended later with elements attributes values
|
||||||
|
* like 'position'
|
||||||
|
*/
|
||||||
|
var elements = (function () {
|
||||||
|
var elements = document.querySelectorAll('[data-aos]');
|
||||||
|
return Array.prototype.map.call(elements, function (node) {
|
||||||
|
return { node: node };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* *******************************************************
|
||||||
|
* AOS (Animate on scroll) - wowjs alternative
|
||||||
|
* made to animate elements on scroll in both directions
|
||||||
|
* *******************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private variables
|
||||||
|
*/
|
||||||
|
var $aosElements = [];
|
||||||
|
var initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default options
|
||||||
|
*/
|
||||||
|
var options = {
|
||||||
|
offset: 120,
|
||||||
|
delay: 0,
|
||||||
|
easing: 'ease',
|
||||||
|
duration: 400,
|
||||||
|
disable: false,
|
||||||
|
once: false,
|
||||||
|
mirror: false,
|
||||||
|
anchorPlacement: 'top-bottom',
|
||||||
|
startEvent: 'DOMContentLoaded',
|
||||||
|
animatedClassName: 'aos-animate',
|
||||||
|
initClassName: 'aos-init',
|
||||||
|
useClassNames: false,
|
||||||
|
disableMutationObserver: false,
|
||||||
|
throttleDelay: 99,
|
||||||
|
debounceDelay: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect not supported browsers (<=IE9)
|
||||||
|
// http://browserhacks.com/#hack-e71d8692f65334173fee715c222cb805
|
||||||
|
var isBrowserNotSupported = function isBrowserNotSupported() {
|
||||||
|
return document.all && !window.atob;
|
||||||
|
};
|
||||||
|
|
||||||
|
var initializeScroll = function initializeScroll() {
|
||||||
|
// Extend elements objects in $aosElements with their positions
|
||||||
|
$aosElements = prepare($aosElements, options);
|
||||||
|
// Perform scroll event, to refresh view and show/hide elements
|
||||||
|
handleScroll($aosElements);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll event to animate elements on scroll
|
||||||
|
*/
|
||||||
|
window.addEventListener('scroll', throttle(function () {
|
||||||
|
handleScroll($aosElements, options.once);
|
||||||
|
}, options.throttleDelay));
|
||||||
|
|
||||||
|
return $aosElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh AOS
|
||||||
|
*/
|
||||||
|
var refresh = function refresh() {
|
||||||
|
var initialize = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
||||||
|
|
||||||
|
// Allow refresh only when it was first initialized on startEvent
|
||||||
|
if (initialize) initialized = true;
|
||||||
|
if (initialized) initializeScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard refresh
|
||||||
|
* create array with new elements and trigger refresh
|
||||||
|
*/
|
||||||
|
var refreshHard = function refreshHard() {
|
||||||
|
$aosElements = elements();
|
||||||
|
|
||||||
|
if (isDisabled(options.disable) || isBrowserNotSupported()) {
|
||||||
|
return disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable AOS
|
||||||
|
* Remove all attributes to reset applied styles
|
||||||
|
*/
|
||||||
|
var disable = function disable() {
|
||||||
|
$aosElements.forEach(function (el, i) {
|
||||||
|
el.node.removeAttribute('data-aos');
|
||||||
|
el.node.removeAttribute('data-aos-easing');
|
||||||
|
el.node.removeAttribute('data-aos-duration');
|
||||||
|
el.node.removeAttribute('data-aos-delay');
|
||||||
|
|
||||||
|
if (options.initClassName) {
|
||||||
|
el.node.classList.remove(options.initClassName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.animatedClassName) {
|
||||||
|
el.node.classList.remove(options.animatedClassName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if AOS should be disabled based on provided setting
|
||||||
|
*/
|
||||||
|
var isDisabled = function isDisabled(optionDisable) {
|
||||||
|
return optionDisable === true || optionDisable === 'mobile' && detect.mobile() || optionDisable === 'phone' && detect.phone() || optionDisable === 'tablet' && detect.tablet() || typeof optionDisable === 'function' && optionDisable() === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializing AOS
|
||||||
|
* - Create options merging defaults with user defined options
|
||||||
|
* - Set attributes on <body> as global setting - css relies on it
|
||||||
|
* - Attach preparing elements to options.startEvent,
|
||||||
|
* window resize and orientation change
|
||||||
|
* - Attach function that handle scroll and everything connected to it
|
||||||
|
* to window scroll event and fire once document is ready to set initial state
|
||||||
|
*/
|
||||||
|
var init = function init(settings) {
|
||||||
|
options = _extends(options, settings);
|
||||||
|
|
||||||
|
// Create initial array with elements -> to be fullfilled later with prepare()
|
||||||
|
$aosElements = elements();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable mutation observing if not supported
|
||||||
|
*/
|
||||||
|
if (!options.disableMutationObserver && !observer.isSupported()) {
|
||||||
|
console.info('\n aos: MutationObserver is not supported on this browser,\n code mutations observing has been disabled.\n You may have to call "refreshHard()" by yourself.\n ');
|
||||||
|
options.disableMutationObserver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe [aos] elements
|
||||||
|
* If something is loaded by AJAX
|
||||||
|
* it'll refresh plugin automatically
|
||||||
|
*/
|
||||||
|
if (!options.disableMutationObserver) {
|
||||||
|
observer.ready('[data-aos]', refreshHard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't init plugin if option `disable` is set
|
||||||
|
* or when browser is not supported
|
||||||
|
*/
|
||||||
|
if (isDisabled(options.disable) || isBrowserNotSupported()) {
|
||||||
|
return disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set global settings on body, based on options
|
||||||
|
* so CSS can use it
|
||||||
|
*/
|
||||||
|
document.querySelector('body').setAttribute('data-aos-easing', options.easing);
|
||||||
|
|
||||||
|
document.querySelector('body').setAttribute('data-aos-duration', options.duration);
|
||||||
|
|
||||||
|
document.querySelector('body').setAttribute('data-aos-delay', options.delay);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initializing
|
||||||
|
*/
|
||||||
|
if (['DOMContentLoaded', 'load'].indexOf(options.startEvent) === -1) {
|
||||||
|
// Listen to options.startEvent and initialize AOS
|
||||||
|
document.addEventListener(options.startEvent, function () {
|
||||||
|
refresh(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
refresh(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.startEvent === 'DOMContentLoaded' && ['complete', 'interactive'].indexOf(document.readyState) > -1) {
|
||||||
|
// Initialize AOS if default startEvent was already fired
|
||||||
|
refresh(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh plugin on window resize or orientation change
|
||||||
|
*/
|
||||||
|
window.addEventListener('resize', debounce(refresh, options.debounceDelay, true));
|
||||||
|
|
||||||
|
window.addEventListener('orientationchange', debounce(refresh, options.debounceDelay, true));
|
||||||
|
|
||||||
|
return $aosElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
var aos = {
|
||||||
|
init: init,
|
||||||
|
refresh: refresh,
|
||||||
|
refreshHard: refreshHard
|
||||||
|
};
|
||||||
|
|
||||||
|
export default aos;
|
||||||