/* ═══════════════════════════════════════════════════ SentiMeter — shared.js Shared Engine: IndoBERT API · Cleaning · Store · Nav ═══════════════════════════════════════════════════ */ 'use strict'; // ─── INDOBERT API CONFIG ──────────────────────────── const INDOBERT_API_URL = '/api/sentiment'; const INDOBERT_HEALTH_URL = '/api/health'; const INDOBERT_BATCH_SIZE = 32; // ─── STOPWORDS INDONESIA ──────────────────────────── const STOPWORDS = new Set([ 'yang','dan','di','ke','dari','dengan','ini','itu','adalah','ada','akan', 'untuk','telah','sudah','dalam','pada','atau','juga','tidak','bisa', 'oleh','sebagai','dapat','lebih','saat','secara','kami','kita','mereka', 'dia','ia','saya','kamu','anda','selama','setelah','sebelum','atas','bawah', 'hal','jika','namun','tapi','tetapi','bahwa','karena','ketika','sehingga', 'namanya','pun','lagi','masih','sama','seperti','bagi','semua','selain', 'maupun','antara','agar','supaya','tanpa','melalui','hingga','sampai', 'bahkan','begitu','meski','meskipun','walaupun','tersebut','nya','lah', 'kah','pernah','selalu','serta','beberapa','suatu','hanya','para', 'tentang','siapa','apa','bagaimana','kapan','dimana','mengapa','kenapa', 'sebuah','seorang','berbagai','banyak','sedikit','lebih','kurang','sangat', 'amat','sekali','cukup','hampir','sudah','belum','sedang','pasti','mungkin', 'tentu','ya','tidak','bukan','jangan','bila','andai','seandainya','adapun', 'adapun','demikian','sehingga','akibat','maka','oleh karena','oleh sebab', ]); // ─── PIPELINE STEP LABELS ─────────────────────────── const PIPELINE_STEPS = [ { id:'lowercase', label:'1. Lowercase', desc:'Mengubah semua huruf menjadi huruf kecil.' }, { id:'url', label:'2. Hapus URL', desc:'Menghapus semua tautan http/https.' }, { id:'mention', label:'3. Hapus Mention', desc:'Menghapus @username.' }, { id:'hashtag', label:'4. Hapus Hashtag', desc:'Mengonversi #tag menjadi kata biasa.' }, { id:'emoji', label:'5. Hapus Emoji', desc:'Menghapus karakter emoji unicode.' }, { id:'special', label:'6. Hapus Karakter Khusus', desc:'Hanya huruf, angka, spasi dipertahankan.' }, { id:'number', label:'7. Hapus Angka', desc:'Menghapus angka yang berdiri sendiri.' }, { id:'whitespace', label:'8. Normalisasi Spasi', desc:'Menghilangkan spasi ganda/leading/trailing.' }, { id:'stopword', label:'9. Hapus Stopwords', desc:'Menghapus kata-kata umum tidak bermakna.' }, ]; // ─── TEXT CLEANER ─────────────────────────────────── function cleanStep(text, stepId) { switch (stepId) { case 'lowercase': return text.toLowerCase(); case 'url': return text.replace(/https?:\/\/[^\s]+/g,'').replace(/www\.[^\s]+/g,''); case 'mention': return text.replace(/@\w+/g,''); case 'hashtag': return text.replace(/#(\w+)/g,'$1'); case 'emoji': return text.replace(/[\u{1F000}-\u{1FFFF}]/gu,'').replace(/[\u{2600}-\u{27BF}]/gu,''); case 'special': return text.replace(/[^a-z0-9 .,]/g,' '); case 'number': return text.replace(/\b\d[\d.,]*\b/g,''); case 'whitespace': return text.replace(/\s+/g,' ').trim(); case 'stopword': return text.split(' ').filter(w=>w.length>1&&!STOPWORDS.has(w)).join(' '); default: return text; } } function cleanText(raw) { let t = raw || ''; for (const s of PIPELINE_STEPS) t = cleanStep(t, s.id); return t; } function cleanSteps(raw) { const steps = []; let t = raw || ''; for (const s of PIPELINE_STEPS) { const before = t; t = cleanStep(t, s.id); steps.push({ ...s, before, after: t }); } return steps; } // ─── CLASSIFY (IndoBERT API) ──────────────────────── /** * Check if the IndoBERT model backend is available. * Returns { ok: true/false, error: string|null } */ async function checkModelHealth() { try { const res = await fetch(INDOBERT_HEALTH_URL, { method: 'GET' }); const data = await res.json(); if (data.status === 'ok') return { ok: true, error: null }; return { ok: false, error: data.error || 'Model tidak tersedia' }; } catch (e) { return { ok: false, error: 'Server IndoBERT tidak dapat dijangkau. Pastikan server Python sedang berjalan.' }; } } /** * Send a batch of texts to the IndoBERT API for sentiment classification. * Returns array of { label: 'Positif'|'Netral'|'Negatif', score: number } * Throws an error if the API call fails. */ async function classifyBatch(texts) { const res = await fetch(INDOBERT_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ texts }), }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.message || `Server error: ${res.status}`); } return await res.json(); } // ─── HELPERS ──────────────────────────────────────── function fmt(n) { if (n >= 1e6) return (n/1e6).toFixed(1)+'M'; if (n >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); } function esc(s) { return String(s||'') .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function avg(arr) { return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : 0; } // ─── DATA STORE (localStorage) ─────────────────── const STORE_KEY = 'sentimeter_data'; function saveData(rows, meta) { try { const dataObj = { rows, meta }; localStorage.setItem(STORE_KEY, JSON.stringify(dataObj)); // Also save to history saveToHistory(dataObj); } catch(e) { // quota exceeded – silently fail console.warn('localStorage full, using memory fallback', e); window._sentimeterData = { rows, meta }; } } function loadData() { try { const raw = localStorage.getItem(STORE_KEY); if (raw) return JSON.parse(raw); } catch(e) {} return window._sentimeterData || null; } function hasData() { return !!loadData(); } // ─── HISTORY STORE (localStorage) ───────────────── const HISTORY_KEY = 'sentimeter_history'; const MAX_HISTORY = 10; // Keep last 10 analyses function getHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); if (raw) return JSON.parse(raw); } catch(e) {} return []; } function saveToHistory(dataObj) { try { let history = getHistory(); // Create a history item (we store the full dataObj to allow reloading) // To save space, we might compress or limit, but for now we store as is // since localStorage typically allows 5MB. We'll add a timestamp ID. const historyItem = { id: 'hist_' + Date.now(), savedAt: new Date().toISOString(), data: dataObj }; // Check if identical filename exists, remove old one history = history.filter(h => h.data.meta.filename !== dataObj.meta.filename); history.unshift(historyItem); // Enforce limit and memory size by checking quota while (history.length > MAX_HISTORY) { history.pop(); } // Attempt saving, if quota exceeded, remove oldest until it fits let saved = false; while (!saved && history.length > 0) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); saved = true; } catch (e) { history.pop(); // Remove oldest if (history.length === 0) { console.warn('History storage completely full.'); break; } } } } catch(e) { console.warn('Could not save to history', e); } } function deleteHistoryItem(id) { try { const history = getHistory(); const itemToDelete = history.find(h => h.id === id); if (!itemToDelete) return; // Check if this item is currently active in the dashboard const currentData = loadData(); if (currentData && currentData.meta && itemToDelete.data && currentData.meta.filename === itemToDelete.data.meta.filename && currentData.meta.processedAt === itemToDelete.data.meta.processedAt) { clearData(); } const newHistory = history.filter(h => h.id !== id); localStorage.setItem(HISTORY_KEY, JSON.stringify(newHistory)); } catch(e) {} } function clearData() { try { localStorage.removeItem(STORE_KEY); window._sentimeterData = null; } catch(e) {} } function loadHistoryItem(id) { const history = getHistory(); const item = history.find(h => h.id === id); if (item && item.data) { saveData(item.data.rows, item.data.meta); return true; } return false; } // ─── PARSE & PROCESS CSV (IndoBERT) ───────────────── /** * Process CSV text: parse, clean, and classify sentiment via IndoBERT API. * This is an async function that sends texts in batches to the backend. * @param {string} csvText - Raw CSV text content * @param {function} onProgress - Progress callback(done, total) * @returns {Promise<{rows: Array, meta: Object}|null>} - null if API error */ async function processCSV(csvText, onProgress) { // 1. First check if model is healthy const health = await checkModelHealth(); if (!health.ok) { throw new Error(health.error); } // 2. Parse CSV const result = Papa.parse(csvText, { header:true, skipEmptyLines:true }); const raw = result.data; if (!raw.length) return { rows: [], meta: {} }; const TEXT_COLS = ['full_text','text','tweet','content','teks','body']; const textCol = TEXT_COLS.find(c => c in raw[0]) || Object.keys(raw[0])[0]; // 3. Build preliminary rows (without sentiment) and collect texts to classify const preRows = []; const textsToClassify = []; const cleanedTexts = []; for (let i = 0; i < raw.length; i++) { const r = raw[i]; const rawTxt = (r[textCol]||'').trim(); if (!rawTxt) continue; const cleaned = cleanText(rawTxt); const fav = parseInt(r.favorite_count)||0; const rt = parseInt(r.retweet_count)||0; const rep = parseInt(r.reply_count)||0; const qot = parseInt(r.quote_count)||0; const media_url = r.media_url || r.media_url_https || r.photo_url || r.image_url || ''; const tweetId = r.id_str || r.tweet_id || r.id || r.rest_id || ''; preRows.push({ tweetId, raw: rawTxt, cleaned, username: r.username||r.user_screen_name||'—', location: (r.location||'').trim()||'—', date: r.created_at||'', lang: r.lang||'in', fav, rt, rep, qot, engagement: fav + rt + rep + qot, media_url, wordsBefore: rawTxt.split(/\s+/).length, wordsAfter: cleaned.split(/\s+/).filter(Boolean).length, }); // Send original text to model for best accuracy textsToClassify.push(rawTxt); cleanedTexts.push(cleaned); } if (preRows.length === 0) return { rows: [], meta: {} }; // 4. Send texts in batches to IndoBERT API const total = textsToClassify.length; const sentimentResults = []; let processed = 0; for (let i = 0; i < total; i += INDOBERT_BATCH_SIZE) { const batch = textsToClassify.slice(i, i + INDOBERT_BATCH_SIZE); const batchResults = await classifyBatch(batch); sentimentResults.push(...batchResults); processed += batch.length; if (onProgress) onProgress(processed, total); // Small yield to allow UI updates await new Promise(r => setTimeout(r, 10)); } // 5. Merge sentiment results into rows const rows = preRows.map((r, idx) => ({ id: idx + 1, ...r, sentiment: sentimentResults[idx].label, confidence: sentimentResults[idx].score, })); // Meta const dates = rows.map(r=>r.date).filter(Boolean).sort(); const meta = { totalRows: rows.length, filename: window._uploadedFilename || 'data.csv', dateMin: dates[0]||'', dateMax: dates[dates.length-1]||'', processedAt: new Date().toISOString(), }; return { rows, meta }; } // ─── THEME MANAGER ────────────────────────────────── const THEME_KEY = 'sentimeter_theme'; function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem(THEME_KEY, theme); const btn = document.getElementById('themeToggleTrack'); if (btn) btn.classList.toggle('on', theme === 'light'); // Update Chart.js colors if charts exist if (window.chartInstances) { const gridCol = theme === 'light' ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.05)'; const textCol = theme === 'light' ? '#4a5568' : '#525b72'; if (typeof Chart !== 'undefined') { Chart.defaults.color = textCol; } } } function initTheme() { const saved = localStorage.getItem(THEME_KEY) || 'dark'; document.documentElement.setAttribute('data-theme', saved); } // Apply theme IMMEDIATELY before any paint initTheme(); // ─── SVG ICONS ─────────────────────────────── const I = { upload: ``, intro: ``, dash: ``, chart: ``, tweets: ``, table: ``, lab: ``, sun: ``, moon: ``, collapse: ``, expand: ``, check: ``, alert: ``, menu: ``, close: ``, history: ``, heart: `` }; // ─── SIDEBAR INJECTOR ─────────────────────────────── const NAV_ITEMS = [ { href:'/', label:'Pengenalan', id:'nav-intro', icon: I.intro, alwaysUnlocked: true }, { href:'upload', label:'Upload Data', id:'nav-upload', icon: I.upload, alwaysUnlocked: true }, { href:'dashboard', label:'Dashboard', id:'nav-dashboard', icon: I.dash }, { href:'analytics', label:'Analytics', id:'nav-analytics', icon: I.chart }, { href:'tweets', label:'Tweet List', id:'nav-tweets', icon: I.tweets }, { href:'data', label:'Data & Tabel', id:'nav-data', icon: I.table }, { href:'cleaning', label:'Cleaning Lab', id:'nav-cleaning', icon: I.lab }, { href:'history', label:'Riwayat Analisis', id:'nav-history', icon: I.history, alwaysUnlocked: true }, { href:'support', label:'Dukungan', id:'nav-support', icon: I.heart, alwaysUnlocked: true }, ]; function injectLayout(activePage) { const hasD = hasData(); const curTheme = localStorage.getItem(THEME_KEY) || 'dark'; const isLight = curTheme === 'light'; const isSidebarCollapsed = localStorage.getItem('sentimeter_sidebar') === 'collapsed'; if (isSidebarCollapsed) document.body.classList.add('sidebar-collapsed'); const navHTML = NAV_ITEMS.map(n => { const isActive = n.id === activePage; const isLocked = !n.alwaysUnlocked && n.id !== 'nav-upload' && !hasD; return ` ${n.icon} ${n.label} ${isLocked?'':''} `; }).join(''); const sidebar = document.getElementById('sidebar'); if (sidebar) { sidebar.innerHTML = ` `; // Inject Mobile Menu Trigger to Topbar const topbar = document.querySelector('.topbar'); if (topbar && !document.getElementById('mobileMenuBtn')) { const menuBtn = document.createElement('button'); menuBtn.id = 'mobileMenuBtn'; menuBtn.className = 'mobile-menu-btn'; menuBtn.innerHTML = I.menu; topbar.prepend(menuBtn); menuBtn.addEventListener('click', () => { document.body.classList.add('sidebar-mobile-open'); }); } // Inject Overlay if (!document.getElementById('sidebarOverlay')) { const overlay = document.createElement('div'); overlay.id = 'sidebarOverlay'; overlay.className = 'sidebar-overlay'; document.body.appendChild(overlay); overlay.addEventListener('click', () => { document.body.classList.remove('sidebar-mobile-open'); }); } document.getElementById('themeToggleBtn').addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme') || 'dark'; const next = current === 'dark' ? 'light' : 'dark'; applyTheme(next); const spanText = document.querySelector('#themeToggleBtn .theme-text'); const iconWrap = document.querySelector('#themeIcon'); if (spanText) spanText.textContent = next === 'light' ? 'Light Mode' : 'Dark Mode'; if (iconWrap) iconWrap.innerHTML = next === 'light' ? I.sun : I.moon; }); document.getElementById('sidebarToggleBtn').addEventListener('click', () => { const isCollapsed = document.body.classList.toggle('sidebar-collapsed'); localStorage.setItem('sentimeter_sidebar', isCollapsed ? 'collapsed' : 'expanded'); const iconWrap = document.querySelector('#collapseIcon'); if (iconWrap) iconWrap.innerHTML = isCollapsed ? I.expand : I.collapse; }); } // Inject Toast Container if (!document.getElementById('toastContainer')) { const toastCont = document.createElement('div'); toastCont.id = 'toastContainer'; toastCont.className = 'toast-container'; document.body.appendChild(toastCont); } } // ─── CHART DEFAULTS ───────────────────────────────── function setChartDefaults() { if (typeof Chart === 'undefined') return; Chart.defaults.color = '#525b72'; Chart.defaults.font.family = "'Inter', sans-serif"; Chart.defaults.font.size = 11; Chart.defaults.plugins.tooltip.padding = 10; Chart.defaults.plugins.tooltip.cornerRadius = 8; Chart.defaults.plugins.tooltip.titleFont = { weight:'600' }; } // ─── COLOR PALETTE ─────────────────────────────────── const C = { pos:'#34d399', posDim:'rgba(52,211,153,0.15)', posMid:'rgba(52,211,153,0.6)', neg:'#f87171', negDim:'rgba(248,113,113,0.15)', negMid:'rgba(248,113,113,0.6)', neu:'#fbbf24', neuDim:'rgba(251,191,36,0.15)', neuMid:'rgba(251,191,36,0.6)', a1:'#6c8fff', a1d:'rgba(108,143,255,0.15)', a2:'#a78bfa', a2d:'rgba(167,139,250,0.15)', a3:'#60d9f9', a3d:'rgba(96,217,249,0.15)', a4:'#f472b6', a4d:'rgba(244,114,182,0.15)', a5:'#fb923c', a5d:'rgba(251,146,60,0.15)', palette: ['#6c8fff','#a78bfa','#60d9f9','#f472b6','#fb923c','#34d399','#f87171','#fbbf24'], }; // grid line shorthand const gridColor = 'rgba(255,255,255,0.05)'; // destroy helper const chartInstances = {}; function mkChart(id, config) { if (chartInstances[id]) { chartInstances[id].destroy(); } const ctx = document.getElementById(id); if (!ctx) return; chartInstances[id] = new Chart(ctx, config); return chartInstances[id]; } // ─── CUSTOM SELECT COMPONENT ──────────────────────── function initCustomSelect(sel, opts = {}) { if (!sel || sel._csdInit) return; sel._csdInit = true; const compact = opts.compact || false; const showDots = opts.showDots || null; // { value: cssColor } // Build wrapper const wrap = document.createElement('div'); wrap.className = 'csd-wrap' + (compact ? ' csd-compact' : ''); // Build trigger const trigger = document.createElement('button'); trigger.type = 'button'; trigger.className = 'csd-trigger'; const labelEl = document.createElement('span'); labelEl.className = 'csd-label'; const chevron = document.createElementNS('http://www.w3.org/2000/svg','svg'); chevron.setAttribute('viewBox','0 0 24 24'); chevron.setAttribute('fill','none'); chevron.setAttribute('stroke','currentColor'); chevron.setAttribute('stroke-width','2.2'); chevron.setAttribute('stroke-linecap','round'); chevron.setAttribute('stroke-linejoin','round'); chevron.classList.add('csd-chevron'); chevron.innerHTML = ''; trigger.appendChild(labelEl); trigger.appendChild(chevron); // Build panel const panel = document.createElement('div'); panel.className = 'csd-panel'; panel.style.display = 'none'; const searchWrap = document.createElement('div'); searchWrap.className = 'csd-search-wrap'; const searchInput = document.createElement('input'); searchInput.className = 'csd-search'; searchInput.type = 'text'; searchInput.placeholder = 'Cari...'; searchInput.autocomplete = 'off'; searchWrap.appendChild(searchInput); const list = document.createElement('div'); list.className = 'csd-list'; panel.appendChild(searchWrap); panel.appendChild(list); wrap.appendChild(trigger); wrap.appendChild(panel); // Insert before native select, hide it sel.parentNode.insertBefore(wrap, sel); sel.style.display = 'none'; wrap.appendChild(sel); // keep in DOM for value access function updateLabel() { const cur = sel.options[sel.selectedIndex]; labelEl.textContent = cur ? cur.text : '—'; } function renderList(query) { const q = (query || '').toLowerCase().trim(); list.innerHTML = ''; let count = 0; Array.from(sel.options).forEach(o => { if (q && !o.text.toLowerCase().includes(q)) return; count++; const item = document.createElement('div'); item.className = 'csd-option' + (o.selected ? ' selected' : ''); item.dataset.value = o.value; if (showDots && showDots[o.value]) { const dot = document.createElement('span'); dot.className = 'csd-dot'; dot.style.background = showDots[o.value]; item.appendChild(dot); } const txt = document.createElement('span'); txt.textContent = o.text; item.appendChild(txt); // Check SVG const check = document.createElementNS('http://www.w3.org/2000/svg','svg'); check.setAttribute('viewBox','0 0 24 24'); check.setAttribute('fill','none'); check.setAttribute('stroke','currentColor'); check.setAttribute('stroke-width','3'); check.setAttribute('stroke-linecap','round'); check.setAttribute('stroke-linejoin','round'); check.classList.add('csd-check'); check.innerHTML = ''; item.appendChild(check); item.addEventListener('mousedown', (e) => { e.preventDefault(); sel.value = o.value; sel.dispatchEvent(new Event('change', { bubbles: true })); updateLabel(); closePanel(); }); list.appendChild(item); }); if (count === 0) { const em = document.createElement('div'); em.className = 'csd-empty'; em.textContent = 'Tidak ada hasil'; list.appendChild(em); } } function openPanel() { panel.style.display = 'block'; trigger.classList.add('open'); searchInput.value = ''; renderList(''); const selItem = list.querySelector('.selected'); if (selItem) selItem.scrollIntoView({ block: 'nearest' }); if (!compact) requestAnimationFrame(() => searchInput.focus()); } function closePanel() { panel.style.display = 'none'; trigger.classList.remove('open'); } trigger.addEventListener('click', (e) => { e.stopPropagation(); panel.style.display === 'none' ? openPanel() : closePanel(); }); searchInput.addEventListener('input', () => renderList(searchInput.value)); searchInput.addEventListener('click', e => e.stopPropagation()); panel.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) closePanel(); }); trigger.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); panel.style.display === 'none' ? openPanel() : closePanel(); } if (e.key === 'Escape') closePanel(); }); // Sync when value changed externally (e.g. reset) const origChange = sel.onchange; sel.addEventListener('_csdRefresh', updateLabel); updateLabel(); return { open: openPanel, close: closePanel, refresh: updateLabel }; } // ─── CUSTOM NUMBER INPUT ──────────────────────────── function initCustomNumber(inp) { if (!inp || inp._cniInit) return; inp._cniInit = true; const wrap = document.createElement('div'); wrap.className = 'cni-wrap'; inp.parentNode.insertBefore(wrap, inp); wrap.appendChild(inp); const arrows = document.createElement('div'); arrows.className = 'cni-arrows'; const btnUp = document.createElement('button'); btnUp.type = 'button'; btnUp.className = 'cni-btn'; // Up chevron btnUp.innerHTML = ``; const btnDown = document.createElement('button'); btnDown.type = 'button'; btnDown.className = 'cni-btn'; // Down chevron btnDown.innerHTML = ``; arrows.appendChild(btnUp); arrows.appendChild(btnDown); wrap.appendChild(arrows); function step(dir) { let val = parseFloat(inp.value) || 0; let s = parseFloat(inp.step) || 1; let min = inp.hasAttribute('min') ? parseFloat(inp.min) : -Infinity; let max = inp.hasAttribute('max') ? parseFloat(inp.max) : Infinity; val += (dir * s); if (val < min) val = min; if (val > max) val = max; // Round to precision to avoid JS float precision issues (like .999999999) const decimals = (String(s).split('.')[1] || '').length; inp.value = decimals ? val.toFixed(decimals) : String(Math.round(val)); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } btnUp.addEventListener('click', (e) => { e.preventDefault(); step(1); inp.focus(); }); btnDown.addEventListener('click', (e) => { e.preventDefault(); step(-1); inp.focus(); }); } // expose globals window.SM = { STOPWORDS, PIPELINE_STEPS, cleanText, cleanSteps, cleanStep, checkModelHealth, classifyBatch, fmt, esc, avg, saveData, loadData, hasData, clearData, processCSV, getHistory, saveToHistory, deleteHistoryItem, loadHistoryItem, injectLayout, setChartDefaults, mkChart, C, gridColor, initCustomSelect, initCustomNumber, showToast: function(message, type = 'success') { const container = document.getElementById('toastContainer'); if (!container) return; const toast = document.createElement('div'); toast.className = `toast toast-${type} toast-enter`; let icon = type === 'error' ? I.alert : I.check; toast.innerHTML = `${icon}${message}`; container.appendChild(toast); void toast.offsetWidth; toast.classList.remove('toast-enter'); setTimeout(() => { toast.classList.add('toast-exit'); toast.addEventListener('transitionend', () => toast.remove()); }, 3000); }, showModal: function(opts) { const { title, message, type = 'success', confirmText = 'OK, Lanjutkan', onConfirm, showCancel = false, cancelText = 'Batal', onCancel, isDanger = false } = opts; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const modal = document.createElement('div'); modal.className = `modal-box modal-${type}`; let icon = type === 'error' ? I.alert : I.check; let btnsHTML = ``; modal.innerHTML = ` ${btnsHTML} `; overlay.appendChild(modal); document.body.appendChild(overlay); // trigger animation void overlay.offsetWidth; overlay.classList.add('active'); // bind confirm const btnConfirm = modal.querySelector(isDanger ? '.modal-btn-danger' : '.btn-primary'); btnConfirm.addEventListener('click', () => { overlay.classList.remove('active'); setTimeout(() => { overlay.remove(); if (onConfirm) onConfirm(); }, 300); }); if (showCancel) { const btnCancel = modal.querySelector('.modal-btn-cancel'); btnCancel.addEventListener('click', () => { overlay.classList.remove('active'); setTimeout(() => { overlay.remove(); if (onCancel) onCancel(); }, 300); }); } } }; // Auto-trigger persistence modal check on page load document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { if (window.SM && window.SM.hasData()) { let isReload = false; // 1. Basic modern performance check (works on desktop) if (window.performance) { const navEntries = performance.getEntriesByType('navigation'); if (navEntries.length > 0 && navEntries[0].type === 'reload') { isReload = true; } else if (performance.navigation && performance.navigation.type === 1) { isReload = true; } } // 2. Bulletproof Mobile Reload Detection (Pull-to-refresh bypasses performance API on iOS/Safari) // When a user clicks a link, we set a flag. If the flag isn't there on load, it was a direct hit/refresh. if (!sessionStorage.getItem('sm_internal_nav')) { // Only consider it a reload if they've been here before in this tab (not the very first click from Twitter) if (sessionStorage.getItem('sm_visited')) { isReload = true; } } // Mark that this tab session has visited the site sessionStorage.setItem('sm_visited', '1'); // Clear the internal nav flag so the NEXT load is assumed a refresh UNLESS a link is clicked sessionStorage.removeItem('sm_internal_nav'); if (isReload) { window.SM.showModal({ title: 'Data Tersimpan Secara Lokal', message: 'Sistem menemukan data dari sesi Anda sebelumnya. Data ini disimpan dengan aman di Local Storage perangkat Anda dan tidak diunggah ke server mana pun.

Catatan Penting: Menghapus cache atau history browser akan menghapus data ini secara permanen.', type: 'success' }); } } }, 300); }); // Attach listener to all internal links to mark navigation vs refresh document.addEventListener('click', (e) => { const link = e.target.closest('a'); if (link && link.href && link.hostname === window.location.hostname) { sessionStorage.setItem('sm_internal_nav', '1'); } });