PageSourceSearch

https://kepriprov.go.id/assets/js/spa.js?v=20260506

js kepriprov.go.id collected 2026-10-01 11:36:53 UTC 46,867 bytes, 1,006 lines download raw bytes

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.