1/** 2 * Portal Kepri - Simple Vanilla SPA (PJAX) 3 * Mencegah full-page reload saat navigasi internal dengan menggunakan Fetch & History API. 4 */ 5document.addEventListener("DOMContentLoaded", () => { 6 7 // -------------------------------------------------------------------------------- // 8 // Global Toast Notification Component 9 // -------------------------------------------------------------------------------- // 10 window.showToast = function(message, type = 'success') { 11 const colors = { 12 success: 'bg-penyengat-green/90 text-white border-green-600/40', 13 error: 'bg-red-500/90 text-white border-red-600/40', 14 info: 'bg-sky-500/90 text-white border-sky-600/40' 15 }; 16 const icons = { 17 success: '<i class="ph-bold ph-check-circle text-xl mr-2"></i>', 18 error: '<i class="ph-bold ph-warning-circle text-xl mr-2"></i>', 19 info: '<i class="ph-bold ph-info text-xl mr-2"></i>' 20 }; 21 22 let container = document.getElementById('toast-container'); 23 if (!container) { 24 container = document.createElement('div'); 25 container.id = 'toast-container'; 26 // Position: Bottom Right 27 container.className = 'fixed bottom-6 right-6 md:bottom-8 md:right-8 z-[9999] flex flex-col gap-3 pointer-events-none items-end'; 28 document.body.appendChild(container); 29 } 30 31 const toast = document.createElement('div'); 32 const colorClass = colors[type] || colors.info; 33 const iconHtml = icons[type] || icons.info; 34 35 toast.className = `flex items-center justify-center px-4 md:px-5 py-3 rounded-2xl shadow-xl shadow-black/10 border backdrop-blur-md transform transition-all duration-300 translate-x-8 opacity-0 w-max leading-none ${colorClass}`; 36 toast.innerHTML = `${iconHtml} <span class="font-bold text-sm tracking-wide mt-0.5">${message}</span>`; 37 38 container.appendChild(toast); 39 40 // Trigger animation 41 requestAnimationFrame(() => { 42 toast.classList.remove('translate-x-8', 'opacity-0'); 43 toast.classList.add('translate-x-0', 'opacity-100'); 44 }); 45 46 // Remove after 3s 47 setTimeout(() => { 48 toast.classList.remove('translate-x-0', 'opacity-100'); 49 toast.classList.add('translate-x-8', 'opacity-0'); 50 setTimeout(() => { 51 toast.remove(); 52 if (container.childNodes.length === 0) container.remove(); 53 }, 300); 54 }, 3000); 55 }; 56 57 // -------------------------------------------------------------------------------- // 58 // Global Info Popup for Placeholder Links (href="#") 59 // -------------------------------------------------------------------------------- // 60 window.showInfoPopup = function( 61 title = 'Layanan Belum Tersedia', 62 message = 'Halaman atau layanan ini sedang dalam proses pengembangan dan akan segera hadir.' 63 ) { 64 let modal = document.getElementById('global-info-popup'); 65 if (!modal) { 66 modal = document.createElement('div'); 67 modal.id = 'global-info-popup'; 68 modal.className = 'fixed inset-0 z-[9000] opacity-0 pointer-events-none transition-opacity duration-200'; 69 modal.style.zIndex = '9000'; 70 modal.innerHTML = ` 71 <div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" data-info-popup-close="1"></div> 72 <div class="absolute inset-0 flex items-center justify-center px-4"> 73 <div class="w-full max-w-md rounded-3xl bg-white shadow-2xl border border-slate-100 p-6 md:p-7 transform scale-95 transition-transform duration-200" data-info-popup-card="1"> 74 <div class="w-12 h-12 rounded-2xl bg-amber-50 text-amber-600 flex items-center justify-center mb-4 border border-amber-100"> 75 <i class="ph-bold ph-info text-2xl"></i> 76 </div> 77 <h3 id="global-info-popup-title" class="text-xl font-extrabold text-slate-800 leading-tight"></h3> 78 <p id="global-info-popup-message" class="mt-2 text-sm md:text-base text-slate-600 leading-relaxed"></p> 79 <button type="button" id="global-info-popup-ok" class="mt-6 w-full bg-penyengat-green hover:bg-penyengat-dark text-white font-bold py-3 rounded-xl transition-colors"> 80 Mengerti 81 </button> 82 </div> 83 </div> 84 `; 85 document.body.appendChild(modal); 86 87 modal.addEventListener('click', (e) => { 88 const target = e.target; 89 if (!(target instanceof HTMLElement)) return; 90 if (target.dataset.infoPopupClose === '1' || target.id === 'global-info-popup-ok') { 91 modal.classList.add('opacity-0', 'pointer-events-none'); 92 const card = modal.querySelector('[data-info-popup-card="1"]'); 93 if (card) card.classList.remove('scale-100'); 94 if (card) card.classList.add('scale-95'); 95 document.body.style.overflow = ''; 96 } 97 }); 98 } 99 100 const titleEl = document.getElementById('global-info-popup-title'); 101 const messageEl = document.getElementById('global-info-popup-message'); 102 if (titleEl) titleEl.textContent = title; 103 if (messageEl) messageEl.textContent = message; 104 105 const card = modal.querySelector('[data-info-popup-card="1"]'); 106 modal.classList.remove('opacity-0', 'pointer-events-none'); 107 if (card) card.classList.remove('scale-95'); 108 if (card) card.classList.add('scale-100'); 109 document.body.style.overflow = 'hidden'; 110 }; 111 112 // -------------------------------------------------------------------------------- // 113 // Reusable Share Dialog 114 // -------------------------------------------------------------------------------- // 115 window.showShareDialog = function({ url = window.location.href, title = document.title || 'Bagikan Konten' } = {}) { 116 let modal = document.getElementById('global-share-dialog'); 117 if (!modal) { 118 modal = document.createElement('div'); 119 modal.id = 'global-share-dialog'; 120 modal.className = 'fixed inset-0 z-[2147483647] opacity-0 pointer-events-none transition-opacity duration-200'; 121 modal.style.zIndex = '2147483647'; 122 modal.innerHTML = ` 123 <div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" data-share-close="1"></div> 124 <div class="absolute inset-0 flex items-end md:items-center justify-center px-0 md:px-4 py-0 md:py-8"> 125 <div class="w-full md:max-w-md rounded-t-3xl md:rounded-3xl bg-white shadow-2xl border border-slate-100 p-5 md:p-7 transform translate-y-6 md:translate-y-0 md:scale-95 transition-all duration-200" data-share-card="1"> 126 <div class="flex items-start justify-between gap-4"> 127 <div> 128 <h3 class="text-xl font-extrabold text-slate-800">Bagikan</h3> 129 <p class="text-sm text-slate-500 mt-1" id="global-share-title"></p> 130 </div> 131 <button type="button" class="w-9 h-9 rounded-full bg-slate-100 hover:bg-slate-200 text-slate-500" data-share-close="1"> 132 <i class="ph-bold ph-x text-lg"></i> 133 </button> 134 </div> 135 136 <div class="mt-4"> 137 <label class="text-xs font-semibold text-slate-500 uppercase tracking-wide">Tautan</label> 138 <div class="mt-2 relative"> 139 <div id="global-share-notice" class="absolute inset-0 z-10 bg-penyengat-green text-white text-xs font-bold rounded-xl flex items-center justify-center gap-2 opacity-0 pointer-events-none transition-all duration-300 translate-y-1"> 140 <i class="ph-bold ph-check-circle text-base"></i> Tautan Berhasil Disalin 141 </div> 142 <div class="flex items-center gap-2"> 143 <input id="global-share-url" type="text" readonly class="w-full h-11 px-3 rounded-xl border border-slate-200 bg-slate-50 text-sm text-slate-700 outline-none focus:border-penyengat-green transition-all"> 144 <button id="global-share-copy" type="button" class="h-11 px-4 roun
144ded-xl bg-slate-900 text-white text-sm font-bold hover:bg-slate-800 transition-colors shrink-0">Salin</button> 145 </div> 146 </div> 147 </div> 148 149 <div class="mt-6 grid grid-cols-2 gap-2.5"> 150 <a id="global-share-wa" target="_blank" rel="noopener noreferrer" class="h-12 rounded-xl bg-emerald-50 text-emerald-700 text-sm font-bold flex items-center justify-center gap-2 hover:bg-emerald-100 transition-colors"> 151 <i class="ph-fill ph-whatsapp-logo text-xl"></i> WhatsApp 152 </a> 153 <a id="global-share-fb" target="_blank" rel="noopener noreferrer" class="h-12 rounded-xl bg-blue-50 text-blue-700 text-sm font-bold flex items-center justify-center gap-2 hover:bg-blue-100 transition-colors"> 154 <i class="ph-fill ph-facebook-logo text-xl"></i> Facebook 155 </a> 156 <a id="global-share-x" target="_blank" rel="noopener noreferrer" class="h-12 rounded-xl bg-slate-100 text-slate-700 text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-200 transition-colors"> 157 <i class="ph-fill ph-x-logo text-xl"></i> X / Twitter 158 </a> 159 <a id="global-share-tg" target="_blank" rel="noopener noreferrer" class="h-12 rounded-xl bg-cyan-50 text-cyan-700 text-sm font-bold flex items-center justify-center gap-2 hover:bg-cyan-100 transition-colors"> 160 <i class="ph-fill ph-telegram-logo text-xl"></i> Telegram 161 </a> 162 </div> 163 </div> 164 </div> 165 `; 166 document.body.appendChild(modal); 167 168 modal.addEventListener('click', (e) => { 169 const target = e.target; 170 if (!(target instanceof HTMLElement)) return; 171 if (target.closest('[data-share-close="1"]')) { 172 modal.classList.add('opacity-0', 'pointer-events-none'); 173 const card = modal.querySelector('[data-share-card="1"]'); 174 if (card) card.classList.remove('scale-100'); 175 if (card) card.classList.add('md:scale-95'); 176 if (card) card.classList.add('translate-y-6'); 177 document.body.style.overflow = ''; 178 } 179 }); 180 181 document.addEventListener('keydown', (e) => { 182 if (e.key !== 'Escape') return; 183 const isOpen = !modal.classList.contains('pointer-events-none'); 184 if (!isOpen) return; 185 modal.classList.add('opacity-0', 'pointer-events-none'); 186 const card = modal.querySelector('[data-share-card="1"]'); 187 if (card) card.classList.remove('scale-100'); 188 if (card) card.classList.add('md:scale-95'); 189 if (card) card.classList.add('translate-y-6'); 190 document.body.style.overflow = ''; 191 }); 192 193 const copyBtn = document.getElementById('global-share-copy'); 194 const shareNotice = document.getElementById('global-share-notice'); 195 if (copyBtn && shareNotice) { 196 copyBtn.addEventListener('click', async () => { 197 const input = document.getElementById('global-share-url'); 198 if (!(input instanceof HTMLInputElement)) return; 199 const text = input.value || ''; 200 let copied = false; 201 try { 202 if (navigator.clipboard && navigator.clipboard.writeText) { 203 await navigator.clipboard.writeText(text); 204 copied = true; 205 } 206 } catch (_) {} 207 208 if (!copied) { 209 input.focus(); 210 input.select(); 211 try { copied = document.execCommand('copy'); } catch (_) { copied = false; } 212 } 213 214 if (copied) { 215 shareNotice.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-1'); 216 shareNotice.classList.add('opacity-100', 'translate-y-0'); 217 setTimeout(() => { 218 shareNotice.classList.remove('opacity-100', 'translate-y-0'); 219 shareNotice.classList.add('opacity-0', 'pointer-events-none', 'translate-y-1'); 220 }, 2000); 221 } 222 }); 223 } 224 } 225 226 const normalizedUrl = String(url || window.location.href); 227 const normalizedTitle = String(title || document.title || 'Bagikan Konten'); 228 const encodedUrl = encodeURIComponent(normalizedUrl); 229 const encodedTitle = encodeURIComponent(normalizedTitle); 230 231 const titleEl = document.getElementById('global-share-title'); 232 const urlInput = document.getElementById('global-share-url'); 233 const wa = document.getElementById('global-share-wa'); 234 const fb = document.getElementById('global-share-fb'); 235 const x = document.getElementById('global-share-x'); 236 const tg = document.getElementById('global-share-tg'); 237 238 if (titleEl) titleEl.textContent = normalizedTitle; 239 if (urlInput instanceof HTMLInputElement) urlInput.value = normalizedUrl; 240 241 if (wa instanceof HTMLAnchorElement) wa.href = `https://api.whatsapp.com/se
241nd?text=${encodedTitle}%20${encodedUrl}`; 242 if (fb instanceof HTMLAnchorElement) fb.href = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`; 243 if (x instanceof HTMLAnchorElement) x.href = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`; 244 if (tg instanceof HTMLAnchorElement) tg.href = `https://telegram.me/share/url?url=${encodedUrl}&text=${encodedTitle}`; 245 246 modal.classList.remove('opacity-0', 'pointer-events-none'); 247 const card = modal.querySelector('[data-share-card="1"]'); 248 if (card) card.classList.remove('md:scale-95'); 249 if (card) card.classList.remove('translate-y-6'); 250 if (card) card.classList.add('scale-100'); 251 document.body.style.overflow = 'hidden'; 252 }; 253 254 document.addEventListener('click', (e) => { 255 const trigger = e.target.closest('[data-share-trigger="1"]'); 256 if (!trigger) return; 257 e.preventDefault(); 258 const url = trigger.getAttribute('data-share-url') || window.location.href; 259 const title = trigger.getAttribute('data-share-title') || document.title || 'Bagikan Konten'; 260 window.showShareDialog({ url, title }); 261 }); 262 263 // -------------------------------------------------------------------------------- // 264 // Lightweight Analytics Heartbeat (throttled) 265 // -------------------------------------------------------------------------------- // 266 (function initAnalyticsHeartbeat() { 267 const HEARTBEAT_INTERVAL_MS = 60_000; 268 let inFlight = false; 269 let lastSentAt = 0; 270 271 const updateVisitorCounter = (id, value, formatter = null) => { 272 const el = document.getElementById(id); 273 if (!el) return; 274 const normalized = Number.isFinite(Number(value)) ? Number(value) : 0; 275 el.textContent = formatter ? formatter(normalized) : String(normalized); 276 }; 277 278 const applyVisitorsToFooter = (visitors) => { 279 if (!visitors || typeof visitors !== 'object') return; 280 updateVisitorCounter('visitor-online', visitors.online ?? 0); 281 updateVisitorCounter('visitor-today', visitors.today ?? 0, (v) => v.toLocaleString('id-ID')); 282 updateVisitorCounter('visitor-yesterday', visitors.yesterday ?? 0, (v) => v.toLocaleString('id-ID')); 283 updateVisitorCounter('visitor-total', visitors.total ?? 0, (v) => v.toLocaleString('id-ID')); 284 }; 285 286 const heartbeat = async () => { 287 if (inFlight) return; 288 if (document.visibilityState === 'hidden') return; 289 290 const now = Date.now(); 291 if (now - lastSentAt < HEARTBEAT_INTERVAL_MS - 500) return; 292 293 inFlight = true; 294 lastSentAt = now; 295 296 try { 297 const payload = { 298 url: window.location.href, 299 path: window.location.pathname + window.location.search, 300 title: document.title || null, 301 referrer: document.referrer || null, 302 }; 303 304 const response = await fetch('/api/heartbeat', { 305 method: 'POST', 306 headers: { 307 'Content-Type': 'application/json', 308 'X-Requested-With': 'XMLHttpRequest', 309 }, 310 credentials: 'same-origin', 311 keepalive: true, 312 body: JSON.stringify(payload), 313 }); 314 315 if (!response.ok) return; 316 const json = await response.json(); 317 applyVisitorsToFooter(json?.visitors ?? null); 318 } catch (error) { 319 // Fail silent: heartbeat should never block UX 320 console.debug('[Analytics Heartbeat]', error); 321 } finally { 322 inFlight = false; 323 } 324 }; 325 326 // Initial pulse + throttled interval 327 setTimeout(() => { void heartbeat(); }, 1200); 328 setInterval(() => { void heartbeat(); }, HEARTBEAT_INTERVAL_MS); 329 330 // Pulse when tab becomes visible again 331 document.addEventListener('visibilitychange', () => { 332 if (document.visibilityState === 'visible') { 333 void heartbeat(); 334 } 335 }); 336 337 // Ensure SPA navigations update path/title on next pulse 338 document.addEventListener('spa:end', () => { 339 void heartbeat(); 340 }); 341 })(); 342 343 // Inisialisasi awal 344 window._isSPA = true; 345 346 function syncNavbarActiveState(nextDoc) { 347 const currentNavbar = document.getElementById("navbar"); 348 const nextNavbar = nextDoc.getElementById("navbar"); 349 if (!currentNavbar || !nextNavbar) return; 350 351 const selector = "a[href], button[onclick], .accordion-content"; 352 const currentNodes = Array.from(currentNavbar.querySelectorAll(selector)); 353 const nextNodes = Array.from(nextNavbar.querySelectorAll(selector)); 354 355 if (currentNodes.length !== nextNodes.length) { 356 return; 357 } 358 359 for (let i = 0; i < currentNodes.length; i += 1) { 360 const currentNode = currentNodes[i]; 361 const nextNode = nextNodes[i]; 362 363 if (!currentNode || !nextNode || currentNode.tagName !== nextNode.tagName) { 364 continue; 365 } 366 367 if (currentNode instanceof HTMLElement && nextNode instanceof HTMLElement) { 368 currentNode.className = nextNode.className; 369 } 370 371 if (currentNode instanceof HTMLAnchorElement && nextNode instanceof HTMLAnchorElement) { 372 const nextAriaCurrent = nextNode.getAttribute("aria-current"); 373 if (nextAriaCurrent) { 374 currentNode.setAttribute("aria-current", nextAriaCurrent); 375 } else { 376 currentNode.removeAttribute("aria-current"); 377 } 378 } 379 } 380 } 381 382 // 1. Intercept link clicks 383 document.addEventListener("click", e => { 384 const a = e.target.closest("a"); 385 386 // Placeholder links: prevent navigation and show info popup 387 if (a) { 388 const rawHref = (a.getAttribute('href') || '').trim(); 389 const isPlaceholderHref = rawHref === '#' || rawHref === '' || /^javascript:\s*void\(0\)/i.test(rawHref); 390 const allowPlaceholder = a.hasAttribute('data-allow-placeholder') || a.classList.contains('allow-placeholder-link'); 391 const isDownloadAction = a.hasAttribute('download'); 392 393 if (isPlaceholderHref && !allowPlaceholder && !isDownloadAction) { 394 e.preventDefault(); 395 window.showInfoPopup( 396 'Fitur Segera Hadir', 397 'Halaman atau layanan ini belum tersedia saat ini. Tim kami sedang menyiapkannya dan akan segera dipublikasikan.' 398 ); 399 return; 400 } 401 } 402 403 // Validasi link internal 404 if ( 405 a && 406 a.href && 407 a.origin === location.origin && // Internal link 408 !a.hash && // Bukan anchor link 409 !a.hasAttribute("download") && // Bukan file download 410 a.target !== "_blank" && // Bukan open new tab 411 !a.classList.contains("no-spa") && // Bukan elemen yg di-exclude 412 !a.closest(".btn-open-service-sheet") // Exclude service modal trigger 413 ) { 414 // Abaikan link file attachment 415 const ext = a.href.split('.').pop().toLowerCase(); 416 if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', 'rar', 'jpg', 'png'].includes(ext)) return; 417 418 // SPA hanya berlaku dalam section yang sama (misal /berita <-> /berita/slug) 419 // Cross-section (misal /berita -> /pengumuman) = full page reload 420 const getSection = (path) => (path.replace(/^\//, '').split('/')[0] || '').toLowerCase(); 421 const currentSection = getSection(location.pathname); 422 const targetSection = getSection(new URL(a.href).pathname); 423
424 // Jika beda section, biarkan browser reload normal 425 if (currentSection !== targetSection) return; 426 427 e.preventDefault(); 428 429 // Auto-close mobile menu if open 430 if (window.closeMobileMenu) window.closeMobileMenu(); 431 432 spaNavigate(a.href); 433 } 434 }); 435 436 // 2. Intercept form GET submissions (seperti fitur pencarian) 437 document.addEventListener("submit", e => { 438 const form = e.target.closest("form"); 439 if (form && form.method.toUpperCase() === "GET" && !form.classList.contains("no-spa")) { 440 e.preventDefault(); 441 const url = new URL(form.action || location.href); 442 const formData = new FormData(form); 443 const params = new URLSearchParams(formData); 444 url.search = params.toString(); 445 spaNavigate(url.toString()); 446 } 447 }); 448 449 // 3. Tangani tombol back/forward browser 450 window.addEventListener("popstate", () => { 451 spaNavigate(location.href, false); 452 }); 453 454 // SPA Navigator logic 455 async function spaNavigate(url, push = true) { 456 const main = document.getElementById("main-content"); 457 if (!main) { 458 location.href = url; // Fallback jika tidak ada main content 459 return; 460 } 461 462 // Tampilkan loading visual state 463 main.style.transition = "opacity 0.2s ease"; 464 main.style.opacity = 0.5; 465 466 // Custom event: SPA Start 467 document.dispatchEvent(new CustomEvent("spa:start", { detail: { url } })); 468 469 try { 470 const res = await fetch(url, { 471 headers: { "X-SPA-Request": "1", "X-Requested-With": "XMLHttpRequest" } 472 }); 473 474 if (!res.ok) throw new Error("Network response was not ok"); 475 476 const html = await res.text(); 477 478 // Parsing DOM baru 479 const parser = new DOMParser(); 480 const doc = parser.parseFromString(html, "text/html"); 481 482 const newMain = doc.getElementById("main-content"); 483 if (newMain) { 484 // Update content 485 main.innerHTML = newMain.innerHTML; 486 487 // Sinkronkan active-state navbar dari dokumen baru 488 syncNavbarActiveState(doc); 489 490 // Eksekusi ulang tag script yang ada di dalam main-content 491 Array.from(main.querySelectorAll("script")).forEac
491h(oldScript => { 492 const newScript = document.createElement("script"); 493 Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value)); 494 newScript.appendChild(document.createTextNode(oldScript.innerHTML)); 495 oldScript.parentNode.replaceChild(newScript, oldScript); 496 }); 497 498 // Update Meta + Title + SEO Tags 499 document.title = doc.title; 500 501 const metaSelector = 'meta[name="description"], meta[property^="og:"], meta[name^="twitter:"], meta[property^="article:"], link[rel="canonical"]'; 502 503 // 1. Remove current dynamic meta tags 504 document.querySelectorAll(metaSelector).forEach(el => el.remove()); 505 506 // 2. Append all new meta tags from fetched doc 507 doc.querySelectorAll(metaSelector).forEach(el => { 508 document.head.appendChild(el.cloneNode(true)); 509 }); 510 511 // Push State 512 if (push) { 513 history.pushState(null, doc.title, url); 514 } 515 516 // Scroll to top 517 window.scrollTo({ top: 0, behavior: "smooth" }); 518 519 // Custom event: SPA End untuk re-inisialisasi JS / Swiper dll. 520 document.dispatchEvent(new CustomEvent("spa:end", { detail: { url, doc } })); 521 522 } else { 523 location.href = url; // Fallback 524 } 525 } catch (error) { 526 console.error("SPA Navigation Failed:", error); 527 location.href = url; // Fallback ke normal reload 528 } finally { 529 main.style.opacity = 1; 530 } 531 } 532 533 // -------------------------------------------------------------------------------- // 534 // Global Event Delegation untuk UI Interaktif (seperti Preview Modal Pengumuman) 535 // -------------------------------------------------------------------------------- // 536 document.addEventListener("click", e => { 537 const btnPreview = e.target.closest(".btn-preview-doc"); 538 if (btnPreview) { 539 e.preventDefault(); 540 const url = btnPreview.dataset.url; 541 const name = btnPreview.dataset.name; 542 const type = btnPreview.dataset.type; 543 544 const modal = document.getElementById('previewModal'); 545 if (!modal) return; 546 547 const modalContent = document.getElementById('previewModalContent'); 548 const iframe = document.getElementById('previewIframe'); 549 const imgEl = document.getElementById('previewImage'); 550 const loading = document.getElementById('previewLoading'); 551 const errorEl = document.getElementById('previewError'); 552 const titleEl = document.getElementById('previewModalTitle'); 553 const iconEl = document.getElementById('previewModalIcon'); 554 555 // Reset UI 556 iframe.src = ''; 557 imgEl.src = ''; 558 iframe.style.opacity = '0'; 559 iframe.style.display = 'block'; 560 imgEl.style.display = 'none'; 561 loading.style.display = 'flex'; 562 errorEl.style.display = 'none'; 563 564 titleEl.textContent = name || 'Pratinjau Dokumen'; 565 566 const isPdf = type === 'PDF'; 567 const isImage = ['JPG','JPEG','PNG','WEBP','GIF'].includes(type); 568 iconEl.className = isPdf 569 ? 'w-10 h-10 rounded-lg bg-red-50 text-red-500 flex items-center justify-center border border-red-100' 570 : (isImage ? 'w-10 h-10 rounded-lg bg-purple-50 text-purple-500 flex items-center justify-center border border-purple-100' 571 : 'w-10 h-10 rounded-lg bg-slate-50 text-slate-500 flex items-center justify-center border border-slate-100'); 572 iconEl.innerHTML = isPdf 573 ? '<i class="ph-fill ph-file-pdf text-xl"></i>' 574 : (isImage ? '<i class="ph-fill ph-file-image text-xl"></i>' : '<i class="ph-fill ph-file text-xl"></i>'); 575 576 modal.classList.remove('opacity-0', 'pointer-events-none'); 577 modalContent.classList.remove('scale-95'); 578 modalContent.classList.add('scale-100'); 579 document.body.style.overflow = 'hidden'; 580 581 if (isImage) { 582 iframe.style.display = 'none'; 583 imgEl.style.display = 'block'; 584 imgEl.src = url; 585 loading.style.display = 'none'; 586 } else { 587 iframe.src = url; 588 } 589 } 590 591 // Listener Close Button Modal atau Klik diluar window 592 if (e.target.closest('.btn-close-modal') || (e.target.id === 'previewModal' && !e.target.closest('#previewModalContent'))) { 593 closeGlobalPreviewModal(); 594 } 595 }); 596 597 document.addEventListener('keydown', e => { 598 if (e.key === 'Escape') closeGlobalPreviewModal(); 599 }); 600 601 function closeGlobalPreviewModal() { 602 const modal = document.getElementById('previewModal'); 603 if (!modal) return; 604 const modalContent = document.getElementById('previewModalContent'); 605 const iframe = document.getElementById('previewIframe'); 606 const imgEl = document.getElementById('previewImage'); 607 608 modal.classList.add('opacity-0', 'pointer-events-none'); 609 modalContent.classList.remove('scale-100'); 610 modalContent.classList.add('scale-95'); 611 document.body.style.overflow = ''; 612 setTimeout(() =>
612 { if (iframe) iframe.src = ''; if (imgEl) imgEl.src = ''; }, 300); 613 } 614 615 // -------------------------------------------------------------------------------- // 616 // Global Image Preview (Zoom In UI) with Prev/Next Navigation 617 // -------------------------------------------------------------------------------- // 618 let globalPreviewImages = []; 619 let currentPreviewIndex = -1; 620 621 function initGlobalImagePreview() { 622 const imagesNodeList = document.querySelectorAll('main img:not(.no-preview)'); 623 globalPreviewImages = Array.from(imagesNodeList).filter(img => { 624 if (img.width > 0 && img.width < 100) return false; 625 if (img.height > 0 && img.height < 100) return false; 626 if (img.closest('a')) return false; 627 if (img.closest('.rounded-full') && img.classList.contains('w-12')) return false; 628 return true; 629 }); 630 631 globalPreviewImages.forEach((img, index) => { 632 img.dataset.previewIndex = index; // Update index to match current array 633 634 if (!img.dataset.previewInit) { 635 img.classList.add('cursor-zoom-in'); 636 img.dataset.previewInit = 'true'; 637 638 img.addEventListener('click', () => { 639 openGlobalImagePreviewByIndex(index); 640 }); 641 } 642 }); 643 } 644 645 // Initialize on first load and after SPA transitions 646 initGlobalImagePreview(); 647 document.addEventListener("spa:end", initGlobalImagePreview); 648 649 function openGlobalImagePreviewByIndex(index) { 650 if (index < 0 || index >= globalPreviewImages.length) return; 651 currentPreviewIndex = index; 652 653 const img = globalPreviewImages[index]; 654 const src = img.getAttribute('src'); 655 if (!src) return; 656 657 const alt = img.getAttribute('alt') || 'Pratinjau Gambar'; 658 let caption = ''; 659 660 const container = img.closest('.w-full.mb-8') || img.closest('.group.relative.aspect-square'); 661 if (container) { 662 const captionEl = container.querySelector('p.text-slate-500.italic') || container.querySelector('p.text-white'); 663 if (captionEl) caption = captionEl.textContent.replace('image', '').trim(); 664 } 665 666 openGlobalImagePreview(src, alt, caption); 667 updatePreviewNavButtons(); 668 } 669 670 function openGlobalImagePreview(src, title, caption) { 671 const modal = document.getElementById('globalImagePreviewModal'); 672 if (!modal) return; 673 674 const imgEl = document.getElementById('globalImagePreviewImg'); 675 const loading = document.getElementById('globalImagePreviewLoading'); 676 const titleEl = document.getElementById('globalImagePreviewTitle'); 677 const captionEl = document.getElementById('globalImagePreviewCaption'); 678 const downloadBtn = document.getElementById('globalImagePreviewDownload'); 679 const content = document.getElementById('globalImagePreviewContent'); 680 681 imgEl.classList.add('opacity-0'); 682 loading.style.display = 'flex'; 683 684 titleEl.textContent = title; 685 if (caption) { 686 captionEl.textContent = caption; 687 captionEl.classList.remove('hidden'); 688 } else { 689 captionEl.classList.add('hidden'); 690 } 691 692 downloadBtn.href = 'javascript:void(0)'; 693 downloadBtn.onclick = async function(e) { 694 e.preventDefault(); 695 const iconHtml = this.innerHTML; 696 this.innerHTML = '<i class="ph-bold ph-spinner animate-spin text-lg md:text-xl"></i>'; 697 try { 698 const res = await fetch(src); 699 if (!res.ok) throw new Error("Network not ok"); 700 const blob = await res.blob(); 701 const blobUrl = window.URL.createObjectURL(blob); 702 const a = document.createElement('a'); 703 a.href = blobUrl; 704 let filename = src.split('/').pop().split('?')[0]; 705 if (!filename || filename.length === 0) filename = 'download.jpg'; 706 a.download = filename; 707 document.body.appendChild(a); 708 a.click(); 709 document.body.removeChild(a); 710 window.URL.revokeObjectURL(blobUrl); 711 } catch (err) { 712 console.error("CORS / Fetch error on download:", err); 713 window.open(src, '_blank'); 714 } finally { 715 this.innerHTML = iconHtml; 716 } 717 }; 718 719 modal.classList.remove('opacity-0', 'pointer-events-none'); 720 content.classList.remove('scale-95'); 721 content.classList.add('scale-100'); 722 document.body.style.overflow = 'hidden'; 723 724 const tempImg = new Image(); 725 tempImg.onload = () => { 726 imgEl.src = src; 727 loading.style.display = 'none'; 728 imgEl.classList.remove('opacity-0'); 729 }; 730 tempImg.onerror = () => { 731 loading.style.display = 'none'; 732 }; 733 tempImg.src = src; 734 } 735 736 function updatePreviewNavButtons() { 737 const prevBtn = document.getElementById('globalImagePreviewPrev'); 738 const nextBtn = document.getElementById('globalImagePreviewNext'); 739 740 if (!prevBtn || !nextBtn) return; 741 742 if (globalPreviewImages.length <= 1) { 743 prevBtn.classList.add('hidden'); 744 nextBtn.classList.add('hidden'); 745 return; 746 } 747 748 if (currentPreviewIndex > 0) { 749 prevBtn.classList.remove('hidden'); 750 } else { 751 prevBtn.classList.add('hidden'); 752 } 753 754 if (currentPreviewIndex < globalPreviewImages.length - 1) { 755 nextBtn.classList.remove('hidden'); 756 } else { 757 nextBtn.classList.add('hidden'); 758 } 759 } 760 761 // Modal Events 762 document.addEventListener("click", e => { 763 if (e.target.closest('.btn-close-image-preview')) { 764 e.preventDefault(); 765 closeGlobalImagePreview(); 766 } 767 768 const prevBtn = e.target.closest('#globalImagePreviewPrev'); 769 if (prevBtn) { 770 e.preventDefault(); 771 openGlobalImagePreviewByIndex(currentPreviewIndex - 1); 772 } 773 774 const nextBtn = e.target.closest('#globalImagePreviewNext'); 775 if (nextBtn) { 776 e.preventDefault(); 777 openGlobalImagePreviewByIndex(currentPreviewIndex + 1); 778 } 779 }); 780 781 document.addEventListener('keydown', e => { 782 const modal = document.getElementById('globalImagePreviewModal'); 783 if (modal && !modal.classList.contains('opacity-0')) { 784 if (e.key === 'Escape') closeGlobalImagePreview(); 785 if (e.key === 'ArrowLeft') openGlobalImagePreviewByIndex(currentPreviewIndex - 1); 786 if (e.key === 'ArrowRight') openGlobalImagePreviewByIndex(currentPreviewIndex + 1); 787 } 788 }); 789 790 function closeGlobalImagePreview() { 791 const modal = document.getElementById('globalImagePreviewModal'); 792 if (!modal) return; 793 const content = document.getElementById('globalImagePreviewContent'); 794 const imgEl = document.getElementById('globalImagePreviewImg'); 795 796 modal.classList.add('opacity-0', 'pointer-events-none'); 797 content.classList.remove('scale-100'); 798 content.classList.add('scale-95'); 799 document.body.style.overflow = ''; 800 currentPreviewIndex = -1; 801 802 setTimeout(() => { 803 if (imgEl) { 804 imgEl.src = ''; 805 imgEl.classList.add('opacity-0'); 806 } 807 }, 300); 808 } 809 810 // -------------------------------------------------------------------------------- // 811 // Public Service Modal / Bottom Sheet Logic 812 // -------------------------------------------------------------------------------- // 813 const serviceModalOverlay = document.getElementById('serviceModalOverlay'); 814 const serviceModal = document.getElementById('serviceModal'); 815 const serviceModalList = document.getElementById('serviceModalList'); 816 const serviceModalTitle = document.getElementById('serviceModalTitle'); 817 const serviceModalDesc = document.getElementById('serviceModalDesc'); 818 const serviceModalLoading = document.getElementById('serviceModalLoading'); 819 const serviceModalEmpty = document.getElementById('serviceModalEmpty'); 820 const closeServiceModalBtn = document.getElementById('closeServiceModal'); 821 const serviceModalAllLink = document.getElementById('serviceModalAllLink'); 822 823 function openServiceModal() { 824 if (!serviceModal || !serviceModalOverlay) return; 825 serviceModalOverlay.classList.add('active'); 826 document.body.style.overflow = 'hidden'; 827 } 828 829 function closeServiceModal() { 830 if (!serviceModal || !serviceModalOverlay) return; 831 serviceModalOverlay.classList.remove('active'); 832 document.body.style.overflow = ''; 833 } 834 835 if (closeServiceModalBtn) { 836 closeServiceModalBtn.addEventListener('click', closeServiceModal); 837 } 838 if (serviceModalOverlay) { 839 serviceModalOverlay.addEventListener('click', closeServiceModal); 840 } 841 842 document.addEventListener('click', e => { 843 const btn = e.target.closest('.btn-open-service-sheet'); 844 if (btn) { 845 e.preventDefault(); 846 e.stopImmediatePropagation(); 847 848 const title = btn.dataset.title; 849 const desc = btn.dataset.desc; 850 const servicesJson = btn.dataset.services; 851 852 // Update Header 853 if (serviceModalTitle) serviceModalTitle.textContent = title; 854 if (serviceModalDesc) serviceModalDesc.textContent = desc; 855 if (serviceModalAllLink) serviceModalAllLink.href = btn.getAttribute('href') || '#'; 856 857 // Reset & Show Content 858 if (serviceModalList) serviceModalList.innerHTML = ''; 859 if (serviceModalLoading) serviceModalLoading.classList.add('hidden'); // No spinner needed 860 if (serviceModalEmpty) serviceModalEmpty.classList.add('hidden'); 861 862 try { 863 const services = servicesJson ? JSON.parse(servicesJson) : []; 864 865 if (services && services.length > 0) {
866 services.forEach(item => { 867 const div = document.createElement('div'); 868 div.className = 'group/item'; 869 870 const isExternal = item.is_external; 871 const target = isExternal ? '_blank' : '_self'; 872 const rel = isExternal ? 'rel="noopener noreferrer"' : ''; 873 874 // Handle icon: URL image vs Icon Class 875 let iconHtml = '<i class="ph-fill ph-circles-four text-2xl"></i>'; 876 if (item.icon) { 877 if (item.icon.startsWith('http') || item.icon.startsWith('/')) { 878 iconHtml = `<img src="${item.icon}" class="w-8 h-8 md:w-9 md:h-9 object-contain group-hover/item:scale-110 transition-transform" alt="${item.title}">`; 879 } else { 880 iconHtml = `<i class="${item.icon} text-2xl"></i>`; 881 } 882 } 883 884 div.innerHTML = ` 885 <a href="${item.target_url}" target="${target}" ${rel} class="flex items-center gap-4 p-4 rounded-2xl border border-slate-100 hover:border-penyengat-green hover:bg-penyengat-softgreen/30 transition-all group-hover/item:-translate-y-0.5 shadow-sm hover:shadow-md"> 886 <div class="w-12 h-12 md:w-14 md:h-14 rounded-xl bg-slate-50 flex items-center justify-center text-penyengat-green group-hover/item:bg-penyengat-softgreen transition-colors shrink-0 overflow-hidden"> 887 ${iconHtml} 888 </div> 889 <div class="flex-grow min-w-0"> 890 <div class="flex items-center justify-between gap-2"> 891 <h4 class="font-bold text-slate-800 text-sm md:text-base truncate group-hover/item:text-penyengat-green transition-colors">${item.title}</h4> 892 ${isExternal ? '<i class="ph-bold ph-arrow-square-out text-slate-300 text-xs"></i>' : '<i class="ph-bold ph-caret-right text-slate-300 text-xs"></i>'} 893 </div> 894 <p class="text-slate-500 text-[11px] md:text-xs line-clamp-1">${item.description || item.agency}</p> 895 </div> 896 </a> 897 `; 898 serviceModalList.appendChild(div); 899 }); 900 } else { 901 if (serviceModalEmpty) serviceModalEmpty.classList.remove('hidden'); 902 } 903 } catch (err) { 904 console.error('Failed to parse services data:', err); 905 if (serviceModalEmpty) serviceModalEmpty.classList.remove('hidden'); 906 } 907 908 // Show UI 909 openServiceModal(); 910 } 911 }); 912 913 914 915 // -------------------------------------------------------------------------------- // 916 // 6. Global Auto Search Functionality 917 // -------------------------------------------------------------------------------- // 918 function debounce(func, timeout = 500) { 919 let timer; 920 return (...args) => { 921 clearTimeout(timer); 922 timer = setTimeout(() => { func.apply(this, args); }, timeout); 923 }; 924 } 925 926 function initAutoSearch() { 927 const searchInputs = document.querySelectorAll('input.auto-search'); 928 searchInputs.forEach(input => { 929 const form = input.closest('form'); 930 if (!form) return; 931 932 const resetBtn = form.querySelector('.search-reset'); 933 934 const handleResetVisibility = () => { 935 if (resetBtn) { 936 if (input.value.length > 0) { 937 resetBtn.classList.remove('hidden'); 938 } else { 939 resetBtn.classList.add('hidden'); 940 } 941 } 942 }; 943 944 // Inisialisasi awal 945 handleResetVisibility(); 946 947 const triggerSearch = debounce(() => { 948 const val = input.value.trim(); 949 // Kirim search jika >= 3 karakter atau saat dikosongkan (untuk reset hasil) 950 if (val.length >= 3 || val.length === 0) { 951 form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); 952 } 953 }, 500); 954 955 input.addEventListener('input', () => { 956 handleResetVisibility(); 957 triggerSearch(); 958 }); 959 960 if (resetBtn) { 961 resetBtn.addEventListener('click', () => { 962 input.value = ''; 963 handleResetVisibility(); 964 input.focus(); 965 form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); 966 }); 967 } 968 }); 969 } 970 971 // -------------------------------------------------------------------------------- // 972 // Back to Top Logic 973 // -------------------------------------------------------------------------------- // 974 function initBackToTop() { 975 const backToTopBtn = document.getElementById('back-to-top'); 976 if (!backToTopBtn) return; 977 978 // Toggle visibility on scroll 979 const handleScroll = () => { 980 if (window.scrollY > 400) { 981 backToTopBtn.classList.remove('translate-y-28', 'opacity-0', 'pointer-events-none'); 982 backToTopBtn.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto'); 983 } else { 984 backToTopBtn.classList.add('translate-y-28', 'opacity-0', 'pointer-events-none'); 985 backToTopBtn.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto'); 986 } 987 }; 988 989 window.removeEventListener('scroll', handleScroll); 990 window.addEventListener('scroll', handleScroll); 991 992 // Scroll action 993 backToTopBtn.onclick = () => { 994 window.scrollTo({ top: 0, behavior: 'smooth' }); 995 }; 996 } 997 998 // Initialize components 999 initAutoSearch(); 1000 initBackToTop(); 1001 1002 document.addEventListener("spa:end", () => { 1003 initAutoSearch(); 1004 initBackToTop(); 1005 }); 1006});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.