Files
Retribusi/public/dashboard/settings.html

1263 lines
44 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pengaturan - Btekno Retribusi Admin</title>
<link rel="stylesheet" href="css/app.css" />
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
.settings-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.list-section {
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid #e5e7eb;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #111827;
}
.btn-add {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
border: none;
background-color: #111827;
color: white;
transition: all 0.15s ease;
}
.btn-add:hover {
background-color: #000000;
}
.list-item {
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: #f9fafb;
}
.list-item-info {
flex: 1;
}
.list-item-title {
font-weight: 600;
color: #111827;
margin-bottom: 0.25rem;
}
.list-item-subtitle {
font-size: 0.85rem;
color: #6b7280;
}
.list-item-actions {
display: flex;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-active {
background: #d1fae5;
color: #065f46;
}
.badge-inactive {
background: #fee2e2;
color: #991b1b;
}
.btn {
padding: 0.35rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.btn-secondary {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background-color: #e5e7eb;
}
.btn-danger {
background-color: #ef4444;
color: white;
}
.btn-danger:hover {
background-color: #dc2626;
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #fff;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 1.1rem;
font-weight: 600;
color: #111827;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
transition: all 0.15s ease;
}
.modal-close:hover {
background: #f3f4f6;
color: #111827;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.35rem;
color: #374151;
}
.form-input,
.form-select {
width: 100%;
border-radius: 0.5rem;
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
background-color: #ffffff;
color: #111827;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: #111827;
box-shadow: 0 0 0 1px #1118271a;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-checkbox input {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.btn-primary {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
background-color: #111827;
color: white;
transition: all 0.15s ease;
flex: 1;
}
.btn-primary:hover {
background-color: #000000;
}
.btn-cancel {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: 1px solid #d1d5db;
background-color: #f3f4f6;
color: #374151;
transition: all 0.15s ease;
}
.btn-cancel:hover {
background-color: #e5e7eb;
}
.error-text {
color: #b91c1c;
font-size: 0.85rem;
margin-top: 0.5rem;
}
.success-text {
color: #059669;
font-size: 0.85rem;
margin-top: 0.5rem;
}
/* Gate Card Styles */
.gate-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1rem;
display: grid;
grid-template-columns: 1fr 400px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1024px) {
.gate-card {
grid-template-columns: 1fr;
}
}
.gate-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gate-info-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.gate-info-label {
font-size: 0.9rem;
font-weight: 500;
color: #374151;
min-width: 110px;
}
.gate-info-value {
font-size: 0.9rem;
color: #111827;
flex: 1;
}
.gate-info-value.url {
color: #059669;
word-break: break-all;
}
.gate-preview {
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
position: relative;
aspect-ratio: 16/9;
min-height: 225px;
}
.gate-preview-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
font-size: 0.9rem;
background: #f3f4f6;
text-transform: lowercase;
}
.gate-preview video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.gate-preview-toggle {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(0, 0, 0, 0.75);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
color: white;
font-size: 0.8rem;
backdrop-filter: blur(4px);
}
.gate-preview-toggle label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
margin: 0;
color: white;
font-weight: 500;
}
.gate-preview-toggle .toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.gate-preview-toggle .toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.gate-preview-toggle .toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.3);
transition: 0.2s;
border-radius: 20px;
}
.gate-preview-toggle .toggle-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
.gate-preview-toggle input:checked + .toggle-slider {
background-color: #10b981;
}
.gate-preview-toggle input:checked + .toggle-slider:before {
transform: translateX(16px);
}
.gate-preview-toggle input:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div class="topbar-title">Pengaturan</div>
<div class="topbar-actions">
<a href="dashboard.html" class="topbar-link">Dashboard</a>
<a href="event.html" class="topbar-link">Events</a>
<button id="logout-button" class="topbar-link">Logout</button>
</div>
</header>
<main class="container">
<div class="settings-layout">
<!-- Lokasi Section -->
<div class="list-section">
<div class="section-header">
<h2 class="section-title">Lokasi</h2>
<button class="btn-add" onclick="openLocationModal()">+ Tambah Lokasi</button>
</div>
<div id="locations-list">
<div style="text-align: center; padding: 2rem; color: #6b7280;">Memuat data...</div>
</div>
</div>
<!-- Gate Section -->
<div class="list-section">
<div class="section-header">
<h2 class="section-title">Gate</h2>
<button class="btn-add" onclick="openGateModal()">+ Tambah Gate</button>
</div>
<div id="gates-list">
<div style="text-align: center; padding: 2rem; color: #6b7280;">Memuat data...</div>
</div>
</div>
</div>
</main>
</div>
<!-- Modal Lokasi -->
<div id="location-modal" class="modal-overlay" onclick="closeModalOnOverlay(event, 'location-modal')">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h3 class="modal-title" id="location-modal-title">Tambah Lokasi</h3>
<button class="modal-close" onclick="closeLocationModal()">&times;</button>
</div>
<div class="modal-body">
<form id="location-form">
<div class="form-group">
<label for="location-code" class="form-label">Kode Lokasi *</label>
<input
type="text"
id="location-code"
class="form-input"
required
pattern="[a-z0-9_\-]{1,64}"
placeholder="contoh: kerkof_01"
/>
<small style="color: #6b7280; font-size: 0.75rem;">Format: huruf kecil, angka, underscore, dash (max 64 karakter)</small>
</div>
<div class="form-group">
<label for="location-name" class="form-label">Nama Lokasi *</label>
<input
type="text"
id="location-name"
class="form-input"
required
placeholder="contoh: Kerkof"
/>
</div>
<div class="form-group">
<label for="location-type" class="form-label">Tipe *</label>
<select id="location-type" class="form-select" required>
<option value="">Pilih Tipe</option>
<option value="kerkof">Kerkof</option>
<option value="pasar">Pasar</option>
<option value="parkir">Parkir</option>
<option value="wisata">Wisata</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<div class="form-checkbox">
<input type="checkbox" id="location-active" checked />
<label for="location-active" class="form-label" style="margin: 0;">Aktif</label>
</div>
</div>
<div id="location-error" class="error-text" style="display: none;"></div>
<div id="location-success" class="success-text" style="display: none;"></div>
<div class="form-actions">
<button type="button" class="btn-cancel" onclick="closeLocationModal()">Batal</button>
<button type="submit" class="btn-primary">Simpan</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal Gate -->
<div id="gate-modal" class="modal-overlay" onclick="closeModalOnOverlay(event, 'gate-modal')">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h3 class="modal-title" id="gate-modal-title">Tambah Gate</h3>
<button class="modal-close" onclick="closeGateModal()">&times;</button>
</div>
<div class="modal-body">
<form id="gate-form">
<div class="form-group">
<label for="gate-location" class="form-label">Lokasi *</label>
<select id="gate-location" class="form-select" required>
<option value="">Pilih Lokasi</option>
</select>
</div>
<div class="form-group">
<label for="gate-code" class="form-label">Kode Gate *</label>
<input
type="text"
id="gate-code"
class="form-input"
required
pattern="[a-z0-9_\-]{1,64}"
placeholder="contoh: gate01"
/>
<small style="color: #6b7280; font-size: 0.75rem;">Format: huruf kecil, angka, underscore, dash (max 64 karakter)</small>
</div>
<div class="form-group">
<label for="gate-name" class="form-label">Nama Gate *</label>
<input
type="text"
id="gate-name"
class="form-input"
required
placeholder="contoh: Gate Utama"
/>
</div>
<div class="form-group">
<label for="gate-direction" class="form-label">Arah *</label>
<select id="gate-direction" class="form-select" required>
<option value="">Pilih Arah</option>
<option value="in">Masuk</option>
<option value="out">Keluar</option>
</select>
</div>
<div class="form-group">
<label for="gate-camera" class="form-label">URL Kamera (HLS)</label>
<input
type="url"
id="gate-camera"
class="form-input"
placeholder="contoh: https://example.com/cam1/index.m3u8"
/>
<small style="color: #6b7280; font-size: 0.75rem;">URL stream HLS untuk live camera</small>
</div>
<div class="form-group">
<div class="form-checkbox">
<input type="checkbox" id="gate-active" checked />
<label for="gate-active" class="form-label" style="margin: 0;">Aktif</label>
</div>
</div>
<div id="gate-error" class="error-text" style="display: none;"></div>
<div id="gate-success" class="success-text" style="display: none;"></div>
<div class="form-actions">
<button type="button" class="btn-cancel" onclick="closeGateModal()">Batal</button>
<button type="submit" class="btn-primary">Simpan</button>
</div>
</form>
</div>
</div>
</div>
<script type="module">
import { Auth } from './js/auth.js';
import {
apiGetLocations,
apiGetGates,
API_CONFIG
} from './js/api.js';
// Check auth
if (!Auth.isAuthenticated()) {
window.location.href = '../index.php';
}
// Logout handler
document.getElementById('logout-button')?.addEventListener('click', () => {
Auth.logout();
});
// Helper untuk build query string
function buildQuery(params = {}) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
search.append(key, value);
}
});
const qs = search.toString();
return qs ? `?${qs}` : '';
}
// API functions untuk CRUD
async function apiCreateLocation(data) {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/locations`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify(data)
});
if (!res.ok) {
const error = await res.json();
if (error.fields) {
const fieldErrors = Object.entries(error.fields).map(([field, msg]) => `${field}: ${msg}`).join(', ');
throw new Error(fieldErrors || error.message || `HTTP ${res.status}`);
}
throw new Error(error.message || `HTTP ${res.status}`);
}
return await res.json();
}
async function apiUpdateLocation(code, data) {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/locations/${code}`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify(data)
});
if (!res.ok) {
const error = await res.json();
if (error.fields) {
const fieldErrors = Object.entries(error.fields).map(([field, msg]) => `${field}: ${msg}`).join(', ');
throw new Error(fieldErrors || error.message || `HTTP ${res.status}`);
}
throw new Error(error.message || `HTTP ${res.status}`);
}
return await res.json();
}
async function apiDeleteLocation(code) {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/locations/${code}`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `HTTP ${res.status}`);
}
return await res.json();
}
async function apiCreateGate(data) {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/gates`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify(data)
});
if (!res.ok) {
const error = await res.json();
if (error.fields) {
const fieldErrors = Object.entries(error.fields).map(([field, msg]) => `${field}: ${msg}`).join(', ');
throw new Error(fieldErrors || error.message || `HTTP ${res.status}`);
}
throw new Error(error.message || `HTTP ${res.status}`);
}
return await res.json();
}
async function apiUpdateGate(locationCode, gateCode, data) {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/gates/${locationCode}/${gateCode}`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify(data)
});
if (!res.ok) {
const error = await res.json();
if (error.fields) {
const fieldErrors = Object.entries(error.fields).map(([field, msg]) => `${field}: ${msg}`).join(', ');
throw new Error(fieldErrors || error.message || `HTTP ${res.status}`);
}
throw new Error(error.message || `HTTP ${res.status}`);
}
return await res.json();
}
async function apiDeleteGate(locationCode, gateCode) {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/gates/${locationCode}/${gateCode}`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `HTTP ${res.status}`);
}
return await res.json();
}
// State
let editingLocation = null;
let editingGate = null;
let locations = [];
// Load locations
async function loadLocations() {
try {
const response = await apiGetLocations({ limit: 100 });
let items = [];
if (Array.isArray(response)) {
items = response;
} else if (response && Array.isArray(response.data)) {
items = response.data;
}
locations = items;
renderLocationsList(items);
updateLocationSelect(items);
} catch (err) {
console.error('loadLocations error', err);
document.getElementById('locations-list').innerHTML =
'<div style="text-align: center; padding: 2rem; color: #b91c1c;">Gagal memuat data lokasi</div>';
}
}
// Load gates
async function loadGates(locationCode = null) {
try {
const response = await apiGetGates(locationCode, { limit: 100 });
let items = [];
if (Array.isArray(response)) {
items = response;
} else if (response && Array.isArray(response.data)) {
items = response.data;
}
renderGatesList(items, locationCode);
} catch (err) {
console.error('loadGates error', err);
const container = document.getElementById('gates-list');
if (container) {
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: #b91c1c;">Gagal memuat data gate</div>';
}
}
}
// Update location select dropdown
function updateLocationSelect(locations) {
const select = document.getElementById('gate-location');
if (!select) return;
select.innerHTML = '<option value="">Pilih Lokasi</option>';
locations.forEach(loc => {
const opt = document.createElement('option');
opt.value = loc.code || loc.location_code || '';
opt.textContent = loc.name || loc.label || opt.value;
select.appendChild(opt);
});
}
// Render locations list
function renderLocationsList(locations) {
const container = document.getElementById('locations-list');
if (!container) return;
if (!locations || locations.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: #6b7280;">Tidak ada lokasi</div>';
return;
}
container.innerHTML = locations.map(loc => {
const code = loc.code || loc.location_code || '';
const name = loc.name || loc.label || code;
const type = loc.type || '-';
const isActive = loc.is_active !== undefined ? loc.is_active : true;
return `
<div class="list-item">
<div class="list-item-info">
<div class="list-item-title">${name} <span class="badge ${isActive ? 'badge-active' : 'badge-inactive'}">${isActive ? 'Aktif' : 'Nonaktif'}</span></div>
<div class="list-item-subtitle">Kode: ${code} | Tipe: ${type}</div>
</div>
<div class="list-item-actions">
<button class="btn btn-secondary" onclick="editLocation('${code}')">Edit</button>
<button class="btn btn-danger" onclick="deleteLocation('${code}')">Hapus</button>
</div>
</div>
`;
}).join('');
}
// Render gates list
function renderGatesList(gates, locationCode = null) {
const container = document.getElementById('gates-list');
if (!container) return;
if (!gates || gates.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: #6b7280;">Tidak ada gate</div>';
return;
}
container.innerHTML = gates.map((gate, index) => {
const location = gate.location_code || '';
const code = gate.gate_code || gate.code || '';
const name = gate.name || gate.label || code;
const direction = gate.direction || '-';
const camera = gate.camera || null;
const isActive = gate.is_active !== undefined ? gate.is_active : true;
const cameraEnabled = camera !== null && camera !== '';
const directionText = direction === 'in' ? 'Masuk' : direction === 'out' ? 'Keluar' : direction;
const locationName = locations.find(l => (l.code || l.location_code) === location)?.name || location;
const gateId = `gate-${location}-${code}-${index}`;
return `
<div class="gate-card">
<div class="gate-info">
<div class="gate-info-row">
<span class="gate-info-label">Lokasi:</span>
<span class="gate-info-value">${locationName}</span>
</div>
<div class="gate-info-row">
<span class="gate-info-label">Gate:</span>
<span class="gate-info-value">${name} | ${code}</span>
</div>
<div class="gate-info-row">
<span class="gate-info-label">Arah:</span>
<span class="gate-info-value">${directionText}</span>
</div>
<div class="gate-info-row">
<span class="gate-info-label">URL Kamera:</span>
<span class="gate-info-value ${cameraEnabled ? 'url' : ''}">${camera || '-'}</span>
</div>
<div class="gate-info-row">
<span class="gate-info-label">Status:</span>
<span class="gate-info-value">
<span class="badge ${isActive ? 'badge-active' : 'badge-inactive'}">${isActive ? 'Aktif' : 'Nonaktif'}</span>
</span>
</div>
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" onclick="editGate('${location}', '${code}')">Edit</button>
<button class="btn btn-danger" onclick="deleteGate('${location}', '${code}')">Hapus</button>
</div>
</div>
<div class="gate-preview">
<div class="gate-preview-toggle">
<label>
<span>Preview</span>
<span class="toggle-switch">
<input type="checkbox" id="preview-${gateId}" onchange="togglePreview('${gateId}', '${camera || ''}', this.checked)" ${cameraEnabled ? '' : 'disabled'}>
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div id="preview-placeholder-${gateId}" class="gate-preview-placeholder">
${cameraEnabled ? 'preview' : 'preview'}
</div>
<video id="preview-video-${gateId}" style="display: none;" muted playsinline></video>
</div>
</div>
`;
}).join('');
}
// HLS video players cache
const hlsPlayers = {};
// Toggle preview
window.togglePreview = function(gateId, cameraUrl, enabled) {
const videoEl = document.getElementById(`preview-video-${gateId}`);
const placeholderEl = document.getElementById(`preview-placeholder-${gateId}`);
if (!videoEl || !placeholderEl) return;
if (enabled && cameraUrl) {
// Show video, hide placeholder
placeholderEl.style.display = 'none';
videoEl.style.display = 'block';
// Initialize HLS player
if (Hls.isSupported()) {
// Destroy existing player if any
if (hlsPlayers[gateId]) {
hlsPlayers[gateId].destroy();
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true
});
hls.loadSource(cameraUrl);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoEl.play().catch(err => {
console.error('Video play error:', err);
});
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('HLS network error, trying to recover...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('HLS media error, trying to recover...');
hls.recoverMediaError();
break;
default:
console.error('HLS fatal error, destroying player');
hls.destroy();
delete hlsPlayers[gateId];
placeholderEl.style.display = 'flex';
videoEl.style.display = 'none';
placeholderEl.textContent = 'Error loading stream';
break;
}
}
});
hlsPlayers[gateId] = hls;
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
videoEl.src = cameraUrl;
videoEl.play().catch(err => {
console.error('Video play error:', err);
});
} else {
console.error('HLS tidak didukung di browser ini');
placeholderEl.style.display = 'flex';
videoEl.style.display = 'none';
placeholderEl.textContent = 'HLS tidak didukung';
}
} else {
// Hide video, show placeholder
videoEl.style.display = 'none';
placeholderEl.style.display = 'flex';
// Destroy HLS player if exists
if (hlsPlayers[gateId]) {
hlsPlayers[gateId].destroy();
delete hlsPlayers[gateId];
}
// Stop video
videoEl.pause();
videoEl.src = '';
}
};
// Modal functions
function openLocationModal() {
editingLocation = null;
document.getElementById('location-form').reset();
document.getElementById('location-code').disabled = false;
document.getElementById('location-modal-title').textContent = 'Tambah Lokasi';
document.getElementById('location-error').style.display = 'none';
document.getElementById('location-success').style.display = 'none';
document.getElementById('location-modal').classList.add('active');
}
function closeLocationModal() {
document.getElementById('location-modal').classList.remove('active');
document.getElementById('location-form').reset();
editingLocation = null;
document.getElementById('location-code').disabled = false;
}
function openGateModal() {
editingGate = null;
document.getElementById('gate-form').reset();
document.getElementById('gate-location').disabled = false;
document.getElementById('gate-code').disabled = false;
document.getElementById('gate-modal-title').textContent = 'Tambah Gate';
document.getElementById('gate-error').style.display = 'none';
document.getElementById('gate-success').style.display = 'none';
document.getElementById('gate-modal').classList.add('active');
}
function closeGateModal() {
document.getElementById('gate-modal').classList.remove('active');
document.getElementById('gate-form').reset();
editingGate = null;
document.getElementById('gate-location').disabled = false;
document.getElementById('gate-code').disabled = false;
}
function closeModalOnOverlay(event, modalId) {
if (event.target.id === modalId) {
if (modalId === 'location-modal') {
closeLocationModal();
} else if (modalId === 'gate-modal') {
closeGateModal();
}
}
}
// Make functions global
window.openLocationModal = openLocationModal;
window.openGateModal = openGateModal;
window.closeLocationModal = closeLocationModal;
window.closeGateModal = closeGateModal;
window.closeModalOnOverlay = closeModalOnOverlay;
// Location form handler
document.getElementById('location-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('location-error');
const successEl = document.getElementById('location-success');
errorEl.style.display = 'none';
successEl.style.display = 'none';
const type = document.getElementById('location-type').value;
if (!type) {
errorEl.textContent = 'Pilih tipe lokasi terlebih dahulu';
errorEl.style.display = 'block';
return;
}
const data = {
code: document.getElementById('location-code').value.trim(),
name: document.getElementById('location-name').value.trim(),
type: type,
is_active: document.getElementById('location-active').checked ? 1 : 0
};
try {
if (editingLocation) {
await apiUpdateLocation(editingLocation, data);
successEl.textContent = 'Lokasi berhasil diupdate!';
} else {
await apiCreateLocation(data);
successEl.textContent = 'Lokasi berhasil ditambahkan!';
}
successEl.style.display = 'block';
await loadLocations();
setTimeout(() => {
closeLocationModal();
successEl.style.display = 'none';
}, 1500);
} catch (err) {
errorEl.textContent = err.message || 'Gagal menyimpan lokasi';
errorEl.style.display = 'block';
}
});
// Gate form handler
document.getElementById('gate-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('gate-error');
const successEl = document.getElementById('gate-success');
errorEl.style.display = 'none';
successEl.style.display = 'none';
const locationCode = document.getElementById('gate-location').value;
if (!locationCode) {
errorEl.textContent = 'Pilih lokasi terlebih dahulu';
errorEl.style.display = 'block';
return;
}
const direction = document.getElementById('gate-direction').value;
if (!direction) {
errorEl.textContent = 'Pilih arah gate terlebih dahulu';
errorEl.style.display = 'block';
return;
}
const cameraValue = document.getElementById('gate-camera').value.trim();
const data = {
location_code: locationCode,
gate_code: document.getElementById('gate-code').value.trim(),
name: document.getElementById('gate-name').value.trim(),
direction: direction,
camera: cameraValue || null,
is_active: document.getElementById('gate-active').checked ? 1 : 0
};
try {
if (editingGate) {
await apiUpdateGate(editingGate.locationCode, editingGate.gateCode, data);
successEl.textContent = 'Gate berhasil diupdate!';
} else {
await apiCreateGate(data);
successEl.textContent = 'Gate berhasil ditambahkan!';
}
successEl.style.display = 'block';
await loadGates();
setTimeout(() => {
closeGateModal();
successEl.style.display = 'none';
}, 1500);
} catch (err) {
errorEl.textContent = err.message || 'Gagal menyimpan gate';
errorEl.style.display = 'block';
}
});
// Location select change - load gates
document.getElementById('gate-location').addEventListener('change', (e) => {
const locationCode = e.target.value;
if (locationCode) {
loadGates(locationCode);
} else {
document.getElementById('gates-list').innerHTML =
'<div style="text-align: center; padding: 2rem; color: #6b7280;">Pilih lokasi untuk melihat gates</div>';
}
});
// Edit location
window.editLocation = async function(code) {
const location = locations.find(l => (l.code || l.location_code) === code);
if (!location) return;
editingLocation = code;
document.getElementById('location-code').value = location.code || location.location_code || '';
document.getElementById('location-code').disabled = true;
document.getElementById('location-name').value = location.name || location.label || '';
document.getElementById('location-type').value = location.type || '';
document.getElementById('location-active').checked = location.is_active !== undefined ? location.is_active : true;
document.getElementById('location-modal-title').textContent = 'Edit Lokasi';
document.getElementById('location-error').style.display = 'none';
document.getElementById('location-success').style.display = 'none';
document.getElementById('location-modal').classList.add('active');
};
// Delete location
window.deleteLocation = async function(code) {
if (!confirm(`Yakin ingin menghapus lokasi ${code}?`)) return;
try {
await apiDeleteLocation(code);
await loadLocations();
alert('Lokasi berhasil dihapus!');
} catch (err) {
alert('Gagal menghapus lokasi: ' + err.message);
}
};
// Edit gate
window.editGate = async function(locationCode, gateCode) {
try {
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/gates/${locationCode}/${gateCode}`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const response = await res.json();
const gate = response.data || response;
editingGate = { locationCode, gateCode };
document.getElementById('gate-location').value = gate.location_code || locationCode;
document.getElementById('gate-location').disabled = true;
document.getElementById('gate-code').value = gate.gate_code || gateCode;
document.getElementById('gate-code').disabled = true;
document.getElementById('gate-name').value = gate.name || '';
document.getElementById('gate-direction').value = gate.direction || '';
document.getElementById('gate-camera').value = gate.camera || '';
document.getElementById('gate-active').checked = gate.is_active !== undefined ? gate.is_active : true;
document.getElementById('gate-modal-title').textContent = 'Edit Gate';
document.getElementById('gate-error').style.display = 'none';
document.getElementById('gate-success').style.display = 'none';
document.getElementById('gate-modal').classList.add('active');
await loadGates();
} catch (err) {
alert('Gagal memuat data gate: ' + err.message);
}
};
// Delete gate
window.deleteGate = async function(locationCode, gateCode) {
if (!confirm(`Yakin ingin menghapus gate ${gateCode}?`)) return;
try {
await apiDeleteGate(locationCode, gateCode);
await loadGates();
alert('Gate berhasil dihapus!');
} catch (err) {
alert('Gagal menghapus gate: ' + err.message);
}
};
// Toggle camera on/off
window.toggleCamera = async function(locationCode, gateCode, enabled) {
try {
// Get current gate data
const url = `${API_CONFIG.BASE_URL}/retribusi/v1/frontend/gates/${locationCode}/${gateCode}`;
const token = localStorage.getItem('token') || '';
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': API_CONFIG.API_KEY,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const response = await res.json();
const gate = response.data || response;
// If enabling camera but no URL exists, prompt user to add URL
if (enabled && (!gate.camera || gate.camera.trim() === '')) {
const cameraUrl = prompt('Masukkan URL kamera (HLS):');
if (!cameraUrl || cameraUrl.trim() === '') {
// Revert toggle
const checkbox = document.querySelector(`input[onchange*="${locationCode}"][onchange*="${gateCode}"]`);
if (checkbox) checkbox.checked = false;
return;
}
// Update with camera URL
await apiUpdateGate(locationCode, gateCode, { camera: cameraUrl.trim() });
} else if (!enabled) {
// Disable camera (set to null)
await apiUpdateGate(locationCode, gateCode, { camera: null });
}
// Reload gates
await loadGates();
} catch (err) {
console.error('toggleCamera error', err);
alert('Gagal mengubah status kamera: ' + err.message);
// Revert toggle on error
const checkbox = document.querySelector(`input[onchange*="${locationCode}"][onchange*="${gateCode}"]`);
if (checkbox) checkbox.checked = !enabled;
}
};
// Init
document.addEventListener('DOMContentLoaded', async () => {
await loadLocations();
await loadGates(); // Load all gates on init
});
</script>
</body>
</html>