Spaces:
Running
Running
| const API_URL = window.location.origin; | |
| let currentUser = "__guest__"; | |
| async function updateGlobalStats() { | |
| try { | |
| const url = `${API_URL}/api/accuracy-report`; | |
| const res = await fetch(url); | |
| const data = await res.json(); | |
| const s = data.stats; | |
| const totalFiles = data.scan_count + data.batch_images; | |
| const adAcc = document.getElementById("ad-accuracy-text"); | |
| if (adAcc) adAcc.innerText = `Akurasi ${s.accuracy}%`; | |
| const adCount = document.getElementById("ad-detected-count"); | |
| if (adCount) adCount.innerText = `${totalFiles} File`; | |
| // Update landing page elements | |
| const landingTotal = document.getElementById("landing-stat-total"); | |
| if (landingTotal) landingTotal.innerText = totalFiles; | |
| const landingCorrect = document.getElementById("landing-stat-correct"); | |
| if (landingCorrect) landingCorrect.innerText = s.correct; | |
| const landingWrong = document.getElementById("landing-stat-wrong"); | |
| if (landingWrong) landingWrong.innerText = s.wrong; | |
| const landingAcc = document.getElementById("landing-stat-accuracy"); | |
| if (landingAcc) landingAcc.innerText = `${s.accuracy}%`; | |
| updateGuestScanCountUI(); | |
| return { s, data, totalFiles }; | |
| } catch (e) { | |
| console.error("Gagal update global stats:", e); | |
| } | |
| } | |
| // --- 1. LOADING SCREEN LOGIC --- | |
| window.onload = () => { | |
| document.getElementById("loading-screen").classList.add("hidden"); | |
| document.getElementById("auth-page").classList.remove("hidden"); | |
| document.getElementById("auth-page").style.display = "flex"; | |
| updateGlobalStats(); | |
| checkApiConnection(); | |
| // Dynamic connection heartbeat every 10 seconds (Poin 1) | |
| setInterval(checkApiConnection, 10000); | |
| }; | |
| async function checkApiConnection() { | |
| const dot = document.getElementById("api-status-dot"); | |
| const text = document.getElementById("api-status-text"); | |
| const indicator = document.getElementById("api-status-indicator"); | |
| if (!dot || !text || !indicator) return; | |
| try { | |
| const res = await fetch(`${API_URL}/api/accuracy-report`); | |
| if (res.ok) { | |
| dot.style.background = "#2ed573"; | |
| dot.style.boxShadow = "0 0 10px #2ed573"; | |
| text.innerText = "API Online"; | |
| text.style.color = "#2ed573"; | |
| indicator.style.borderColor = "rgba(46, 213, 115, 0.3)"; | |
| } else { | |
| throw new Error(); | |
| } | |
| } catch (e) { | |
| dot.style.background = "#ff4757"; | |
| dot.style.boxShadow = "0 0 10px #ff4757"; | |
| text.innerText = "API Offline"; | |
| text.style.color = "#ff4757"; | |
| indicator.style.borderColor = "rgba(255, 71, 87, 0.3)"; | |
| } | |
| } | |
| // --- 2. ANIMASI PARTIKEL SENTUHAN LAYAR --- | |
| document.addEventListener("click", (e) => { | |
| createParticles(e.clientX, e.clientY); | |
| }); | |
| function createParticles(x, y) { | |
| const colors = ['#FFD700', '#0052D4', '#FFFACD', '#4D8BF5']; | |
| for (let i = 0; i < 8; i++) { // Buat 8 partikel per klik | |
| const particle = document.createElement("div"); | |
| particle.classList.add("click-particle"); | |
| const size = Math.random() * 10 + 5; // Ukuran 5-15px | |
| particle.style.width = `${size}px`; | |
| particle.style.height = `${size}px`; | |
| particle.style.left = `${x}px`; | |
| particle.style.top = `${y}px`; | |
| particle.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]; | |
| // Arah random terbang partikel | |
| const tx = (Math.random() - 0.5) * 150; | |
| const ty = (Math.random() - 0.5) * 150; | |
| particle.style.setProperty('--tx', `${tx}px`); | |
| particle.style.setProperty('--ty', `${ty}px`); | |
| document.body.appendChild(particle); | |
| setTimeout(() => particle.remove(), 800); // Hapus partikel setelah animasi selesai | |
| } | |
| } | |
| // --- 3. AUTH LOGIC --- | |
| function switchTab(tab) { | |
| document.getElementById("form-login").classList.toggle("hidden", tab !== "login"); | |
| document.getElementById("form-register").classList.toggle("hidden", tab !== "register"); | |
| document.getElementById("tab-login").classList.toggle("active", tab === "login"); | |
| document.getElementById("tab-register").classList.toggle("active", tab === "register"); | |
| } | |
| function updateUserTrustScoreUI(score) { | |
| const scoreEl = document.getElementById("user-trust-score"); | |
| const badgeEl = document.getElementById("user-trust-badge"); | |
| if (!scoreEl || !badgeEl) return; | |
| scoreEl.innerText = score; | |
| if (score >= 80) { | |
| badgeEl.innerText = "π‘οΈ Pakar"; | |
| badgeEl.style.background = "rgba(46, 204, 113, 0.15)"; | |
| badgeEl.style.color = "var(--success)"; | |
| badgeEl.style.borderColor = "rgba(46, 204, 113, 0.25)"; | |
| } else if (score < 50) { | |
| badgeEl.innerText = "β οΈ Dicurigai"; | |
| badgeEl.style.background = "rgba(255, 71, 87, 0.15)"; | |
| badgeEl.style.color = "var(--danger)"; | |
| badgeEl.style.borderColor = "rgba(255, 71, 87, 0.25)"; | |
| } else { | |
| badgeEl.innerText = "Standar"; | |
| badgeEl.style.background = "rgba(255, 215, 0, 0.15)"; | |
| badgeEl.style.color = "var(--yellow-main)"; | |
| badgeEl.style.borderColor = "rgba(255, 215, 0, 0.25)"; | |
| } | |
| } | |
| async function handleLogin(e) { | |
| e.preventDefault(); | |
| const u = document.getElementById("login-user").value; | |
| const p = document.getElementById("login-pass").value; | |
| const formData = new URLSearchParams({ username: u, password: p }); | |
| try { | |
| const res = await fetch(`${API_URL}/api/login`, { method: "POST", body: formData }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| currentUser = data.username; | |
| document.getElementById("user-name").innerText = data.name; | |
| updateUserTrustScoreUI(data.trust_score || 50); | |
| const inputImg = document.getElementById("input-image"); | |
| if (inputImg) { | |
| if (currentUser === "Sandikad") { | |
| inputImg.setAttribute("multiple", "multiple"); | |
| } else { | |
| inputImg.removeAttribute("multiple"); | |
| } | |
| } | |
| const downloadBtn = document.getElementById("btn-download-feedback"); | |
| if (downloadBtn) { | |
| if (currentUser === "Sandikad") { | |
| downloadBtn.style.display = "inline-block"; | |
| } else { | |
| downloadBtn.style.display = "none"; | |
| } | |
| } | |
| // Pulihkan visibilitas elemen UI dari mode tamu | |
| document.getElementById("user-trust-container").style.display = "flex"; | |
| document.getElementById("guest-scan-container").style.display = "none"; | |
| document.getElementById("menu-batch-test").style.display = "block"; | |
| document.getElementById("menu-history").style.display = "block"; | |
| document.getElementById("menu-accuracy").style.display = "block"; | |
| document.getElementById("auth-page").classList.add("hidden"); | |
| document.getElementById("main-app").classList.remove("hidden"); | |
| document.getElementById("app-style").href = "style.css"; | |
| closeAuthModal(); | |
| loadDashboard(); | |
| loadHistory(); | |
| } else { | |
| document.getElementById("login-error").innerText = data.detail; | |
| } | |
| } catch (err) { | |
| document.getElementById("login-error").innerText = "Gagal menghubungi backend"; | |
| } | |
| } | |
| async function handleRegister(e) { | |
| e.preventDefault(); | |
| const n = document.getElementById("reg-name").value; | |
| const u = document.getElementById("reg-user").value; | |
| const p = document.getElementById("reg-pass").value; | |
| const formData = new URLSearchParams({ name: n, username: u, password: p }); | |
| const res = await fetch(`${API_URL}/api/register`, { method: "POST", body: formData }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| alert("Registrasi berhasil! Silakan login."); | |
| switchTab('login'); | |
| } else { | |
| document.getElementById("reg-error").innerText = data.detail; | |
| } | |
| } | |
| function handleLogout() { | |
| currentUser = "__guest__"; | |
| const inputImg = document.getElementById("input-image"); | |
| if (inputImg) { | |
| inputImg.removeAttribute("multiple"); | |
| } | |
| const downloadBtn = document.getElementById("btn-download-feedback"); | |
| if (downloadBtn) { | |
| downloadBtn.style.display = "none"; | |
| } | |
| // Pulihkan kembali elemen UI ke default untuk login berikutnya | |
| document.getElementById("user-trust-container").style.display = "flex"; | |
| document.getElementById("guest-scan-container").style.display = "none"; | |
| document.getElementById("menu-batch-test").style.display = "block"; | |
| document.getElementById("menu-history").style.display = "block"; | |
| document.getElementById("menu-accuracy").style.display = "block"; | |
| document.getElementById("main-app").classList.add("hidden"); | |
| document.getElementById("auth-page").classList.remove("hidden"); | |
| document.getElementById("app-style").href = "template/style.css"; | |
| } | |
| // --- 4. NAVIGATION LOGIC --- | |
| function showSection(sectionId) { | |
| document.querySelectorAll(".section").forEach(s => s.classList.remove("active")); | |
| document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active")); | |
| document.getElementById(`sec-${sectionId}`).classList.add("active"); | |
| // Safe sidebar nav item highlight resolution (works from sidebar and shortcuts!) | |
| const navItems = document.querySelectorAll(".nav-item"); | |
| navItems.forEach(n => { | |
| if (n.getAttribute("onclick") && n.getAttribute("onclick").includes(`'${sectionId}'`)) { | |
| n.classList.add("active"); | |
| } | |
| }); | |
| if (sectionId === "dashboard") loadDashboard(); | |
| if (sectionId === "history") loadHistory(); | |
| if (sectionId === "accuracy") loadAccuracyReport(); | |
| } | |
| async function loadDashboard() { | |
| if (!currentUser) return; | |
| try { | |
| const statsData = await updateGlobalStats(); | |
| if (!statsData) return; | |
| const { s, data } = statsData; | |
| const accColor = s.accuracy >= 70 ? "var(--success)" : s.accuracy >= 40 ? "var(--blue-dark)" : "var(--danger)"; | |
| document.getElementById("dashboard-stats").innerHTML = ` | |
| <div class="stat-card blue"><h3>${s.total}</h3><p>Total Test</p></div> | |
| <div class="stat-card yellow"><h3>${s.correct}</h3><p>Benar</p></div> | |
| <div class="stat-card blue"><h3>${s.wrong}</h3><p>Salah</p></div> | |
| <div class="stat-card yellow"><h3 style="color:${accColor}">${s.accuracy}%</h3><p>Akurasi</p></div>`; | |
| document.getElementById("dashboard-learning").innerHTML = ` | |
| Data pembelajaran: <b style="color:var(--yellow-main)">${data.learning_data_count}</b> gambar siap training | |
| <button class="btn-scan" style="padding:5px 15px;font-size:12px" onclick="showSection('accuracy');event.target.blur()">Detail β</button>`; | |
| } catch (e) { } | |
| } | |
| // --- 5. FILE SCANNING LOGIC --- | |
| document.getElementById("input-image").addEventListener("change", (e) => { | |
| const files = e.target.files; | |
| if (files.length > 1) { | |
| document.getElementById("img-name").innerText = `${files.length} file dipilih`; | |
| } else { | |
| document.getElementById("img-name").innerText = files[0]?.name || "Tidak ada file dipilih"; | |
| } | |
| }); | |
| async function simulateForensicAnalysis(resultBox, fetchPromise) { | |
| const steps = [ | |
| "[Step 1/12] Metadata normal.", | |
| "[Step 2/12] Analisis Pixel (Komite Binary). Score: 70%", | |
| "[Step 3/12] Analisis Pola Sensor CFA selesai.", | |
| "[Step 4/12] Pencarian jejak Hex/Binary selesai.", | |
| "[Step 5/12] Pemetaan Noise selesai.", | |
| "[Step 6/12] Analisis Geometri selesai.", | |
| "[Step 7/12] Pencarian Artifact visual selesai.", | |
| "[Step 8/12] Verifikasi Tipe File selesai.", | |
| "[Step 9/12] Analisis Konsistensi Pencahayaan selesai.", | |
| "[Step 10/12] Pemindaian Duplikasi Pixel selesai.", | |
| "[Step 11/12] Analisis Pola Frekuensi GAN selesai.", | |
| "[Step 12/12] Inspeksi Tingkat Error (ELA) selesai." | |
| ]; | |
| resultBox.innerHTML = ""; | |
| resultBox.classList.remove("hidden", "ai", "real"); | |
| const title = document.createElement("h3"); | |
| title.style.color = "var(--yellow-main)"; | |
| title.style.marginBottom = "15px"; | |
| title.innerText = "β³ Melakukan Analisis Forensik Citra..."; | |
| resultBox.appendChild(title); | |
| const container = document.createElement("div"); | |
| container.style.textAlign = "left"; | |
| container.style.fontSize = "13px"; | |
| container.style.fontFamily = "monospace"; | |
| container.style.color = "#2ed573"; | |
| container.style.lineHeight = "1.7"; | |
| container.style.padding = "15px"; | |
| container.style.background = "rgba(0,0,0,0.4)"; | |
| container.style.border = "1px solid rgba(255, 215, 0, 0.2)"; | |
| container.style.borderRadius = "12px"; | |
| container.style.maxHeight = "220px"; | |
| container.style.overflowY = "auto"; | |
| container.style.marginBottom = "10px"; | |
| resultBox.appendChild(container); | |
| for (let i = 0; i < steps.length; i++) { | |
| const stepDiv = document.createElement("div"); | |
| stepDiv.innerText = steps[i]; | |
| stepDiv.style.opacity = "0"; | |
| stepDiv.style.transform = "translateX(-8px)"; | |
| stepDiv.style.transition = "all 0.15s ease-out"; | |
| container.appendChild(stepDiv); | |
| stepDiv.offsetHeight; | |
| stepDiv.style.opacity = "1"; | |
| stepDiv.style.transform = "translateX(0)"; | |
| container.scrollTop = container.scrollHeight; | |
| const delay = i === 1 ? 300 : (80 + Math.random() * 100); | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| } | |
| const waitDiv = document.createElement("div"); | |
| waitDiv.innerText = "π Menyambungkan ke Model Ensemble v4..."; | |
| waitDiv.style.color = "var(--yellow-main)"; | |
| waitDiv.style.fontWeight = "bold"; | |
| waitDiv.style.marginTop = "8px"; | |
| container.appendChild(waitDiv); | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| function toggleForensicLogs(btn) { | |
| const content = btn.nextElementSibling; | |
| const arrow = btn.querySelector("span:last-child"); | |
| if (content.classList.contains("hidden")) { | |
| content.classList.remove("hidden"); | |
| arrow.innerText = "β²"; | |
| } else { | |
| content.classList.add("hidden"); | |
| arrow.innerText = "βΌ"; | |
| } | |
| } | |
| function toggleBulkForensicLogs(btn) { | |
| const logsDiv = btn.nextElementSibling; | |
| if (logsDiv.classList.contains("hidden")) { | |
| logsDiv.classList.remove("hidden"); | |
| btn.innerText = "βοΈ Tutup Log"; | |
| } else { | |
| logsDiv.classList.add("hidden"); | |
| btn.innerText = "ποΈ Lihat Log"; | |
| } | |
| } | |
| async function scanFile() { | |
| const fileInput = document.getElementById("input-image"); | |
| const resultBox = document.getElementById("result-image"); | |
| if (currentUser === "__guest__") { | |
| let count = parseInt(localStorage.getItem("guest_scan_count") || "0"); | |
| if (count >= 5) { | |
| alert("Batas scan gratis Anda (5 kali) sudah habis!\nSilakan Login atau Register terlebih dahulu untuk scan tanpa batas."); | |
| handleLogout(); | |
| return; | |
| } | |
| } | |
| if (!fileInput.files || fileInput.files.length === 0) return alert("Pilih file terlebih dahulu!"); | |
| // Case 1: Multiple files (Sandikad only) | |
| if (fileInput.files.length > 1) { | |
| if (currentUser !== "Sandikad") { | |
| alert("Akses Ditolak: Hanya pengguna 'Sandikad' yang diizinkan untuk memindai banyak gambar sekaligus."); | |
| return; | |
| } | |
| const files = Array.from(fileInput.files); | |
| // Check file size limits | |
| for (let f of files) { | |
| if (f.size > 10 * 1024 * 1024) { | |
| return alert(`Ukuran file "${f.name}" melebihi batas 10MB!`); | |
| } | |
| } | |
| // Show unified progress log | |
| resultBox.className = "result-box"; | |
| resultBox.classList.remove("hidden", "ai", "real"); | |
| resultBox.innerHTML = ` | |
| <h3 style="color: var(--yellow-main); margin-bottom: 15px;">β³ Melakukan Analisis Forensik Citra Massal (${files.length} Gambar)...</h3> | |
| <div id="bulk-progress-log" style="text-align: left; font-size: 13px; font-family: monospace; color: #2ed573; line-height: 1.7; padding: 15px; background: rgba(0,0,0,0.4); border: 1px solid rgba(255, 215, 0, 0.2); border-radius: 12px; max-height: 250px; overflow-y: auto;"> | |
| </div> | |
| `; | |
| const progressLog = document.getElementById("bulk-progress-log"); | |
| const scanResults = []; | |
| let lastTrustScore = 50; | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| const logItem = document.createElement("div"); | |
| logItem.innerText = `π [${i+1}/${files.length}] Memindai ${file.name}...`; | |
| progressLog.appendChild(logItem); | |
| progressLog.scrollTop = progressLog.scrollHeight; | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| formData.append("username", currentUser); | |
| try { | |
| const res = await fetch(`${API_URL}/api/scan-image`, { method: "POST", body: formData }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| scanResults.push({ file, success: true, data }); | |
| logItem.innerHTML = `β [${i+1}/${files.length}] Selesai: <span style="color: ${data.is_ai ? "var(--danger)" : "var(--success)"}; font-weight: bold;">${data.is_ai ? "AI" : "REAL"}</span> (${data.accuracy}%) - ${file.name}`; | |
| if (data.trust_score !== undefined) { | |
| lastTrustScore = data.trust_score; | |
| } | |
| } else { | |
| scanResults.push({ file, success: false, error: data.detail || "Gagal dipindai" }); | |
| logItem.innerHTML = `β [${i+1}/${files.length}] Gagal: ${data.detail || "Error"} - ${file.name}`; | |
| logItem.style.color = "var(--danger)"; | |
| } | |
| } catch (err) { | |
| scanResults.push({ file, success: false, error: "Gagal menghubungi server" }); | |
| logItem.innerHTML = `β [${i+1}/${files.length}] Gagal koneksi: ${file.name}`; | |
| logItem.style.color = "var(--danger)"; | |
| } | |
| progressLog.scrollTop = progressLog.scrollHeight; | |
| // Small delay to make it feel natural | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| } | |
| // Render multiple results view | |
| updateUserTrustScoreUI(lastTrustScore); | |
| updateGlobalStats(); | |
| let tbodyHtml = ""; | |
| scanResults.forEach((r, idx) => { | |
| if (!r.success) { | |
| tbodyHtml += ` | |
| <tr> | |
| <td>${r.file.name}</td> | |
| <td colspan="5" style="color: var(--danger)">Error: ${r.error}</td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| const data = r.data; | |
| const predColor = data.is_ai ? "var(--danger)" : "var(--success)"; | |
| const predText = data.is_ai ? "AI" : "REAL"; | |
| // Badges | |
| let badgesHtml = ""; | |
| if (data.is_grayscale) { | |
| badgesHtml += `<span class="badge" style="background:rgba(149,165,166,0.15);color:#bdc3c7;border:1px solid rgba(149,165,166,0.25);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:4px">βͺ Monokrom</span>`; | |
| } | |
| if (data.is_dark) { | |
| badgesHtml += `<span class="badge" style="background:rgba(230,126,34,0.1);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:4px">π Low Light</span>`; | |
| } | |
| if (data.is_outlier) { | |
| badgesHtml += `<span class="badge" style="background:rgba(255,71,87,0.1);color:var(--danger);border:1px solid rgba(255,71,87,0.25);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:4px">π‘ Outlier</span>`; | |
| } | |
| if (data.is_trap) { | |
| badgesHtml += `<span class="badge" style="background:rgba(230,126,34,0.15);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:4px">π‘οΈ Trap</span>`; | |
| } | |
| // Forensic logs toggle content | |
| let stepsHtml = ""; | |
| if (data.forensic_analysis_logs) { | |
| for (let s = 1; s <= 12; s++) { | |
| const stepText = data.forensic_analysis_logs[`step_${s}`] || `[Step ${s}/12] Analisis selesai.`; | |
| stepsHtml += `<div style="margin-bottom: 4px; border-bottom: 1px dashed rgba(255,255,255,0.05); padding-bottom: 2px;">${stepText}</div>`; | |
| } | |
| } | |
| tbodyHtml += ` | |
| <tr> | |
| <td style="font-weight: bold;">${data.filename}</td> | |
| <td style="color: ${predColor}; font-weight: bold;">${predText}</td> | |
| <td style="color: ${predColor}">${data.accuracy}%</td> | |
| <td>${badgesHtml || "-"}</td> | |
| <td> | |
| <button class="btn-refresh" style="padding: 4px 10px; font-size: 11px; margin: 0;" onclick="toggleBulkForensicLogs(this)">ποΈ Lihat Log</button> | |
| <div class="logs-content hidden" style="margin-top: 8px; background: rgba(0,0,0,0.5); border: 1px dashed rgba(255,215,0,0.3); border-radius: 8px; padding: 10px; font-family: monospace; font-size: 11px; color: #2ed573; line-height: 1.5; text-align: left; max-height: 150px; overflow-y: auto;"> | |
| ${stepsHtml} | |
| </div> | |
| </td> | |
| <td> | |
| <div style="display: flex; gap: 6px; justify-content: center; align-items: center;"> | |
| <button class="btn-scan" style="padding: 4px 10px; font-size: 11px; margin: 0; background: var(--success); border-color: var(--success);" onclick="confirmSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">β Benar</button> | |
| <button class="btn-scan" style="padding: 4px 10px; font-size: 11px; margin: 0; background: var(--danger); border-color: var(--danger);" onclick="correctSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">β Salah</button> | |
| </div> | |
| </td> | |
| </tr> | |
| `; | |
| }); | |
| resultBox.innerHTML = ` | |
| <h3 style="color: var(--yellow-main); margin-bottom: 15px;">π Hasil Analisis Citra Massal (${files.length} Gambar)</h3> | |
| <div class="table-wrap"> | |
| <table class="history-table"> | |
| <thead> | |
| <tr> | |
| <th>Gambar</th> | |
| <th>Prediksi</th> | |
| <th>Keakuratan</th> | |
| <th>Info Deteksi</th> | |
| <th>Log Forensik (12 Langkah)</th> | |
| <th>Tindakan / Feedback</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${tbodyHtml} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Case 2: Single file | |
| const file = fileInput.files[0]; | |
| if (file.size > 10 * 1024 * 1024) return alert("Ukuran file melebihi batas 10MB!"); | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| formData.append("username", currentUser); | |
| const fetchPromise = fetch(`${API_URL}/api/scan-image`, { method: "POST", body: formData }); | |
| await simulateForensicAnalysis(resultBox, fetchPromise); | |
| try { | |
| const res = await fetchPromise; | |
| const data = await res.json(); | |
| // CEK OTOMATIS GAMBAR HITAM PUTIH UNTUK TAMPILKAN BANNER ALERT | |
| const warningBanner = document.getElementById("warning-banner-monokrom"); | |
| if (warningBanner) { | |
| if (data.is_monochrome_detected === true) { | |
| warningBanner.style.display = "block"; | |
| } else { | |
| warningBanner.style.display = "none"; | |
| } | |
| } | |
| if (res.ok) { | |
| if (currentUser === "__guest__") { | |
| let count = parseInt(localStorage.getItem("guest_scan_count") || "0"); | |
| count++; | |
| localStorage.setItem("guest_scan_count", count.toString()); | |
| updateGuestScanCountUI(); | |
| } | |
| const statusClass = data.is_ai ? "ai" : "real"; | |
| const statusText = data.is_ai ? "β οΈ GAMBAR PALSU (AI)" : "β GAMBAR ASLI (REAL)"; | |
| const statusColor = data.is_ai ? "var(--danger)" : "var(--success)"; | |
| let logsHtml = ''; | |
| if (data.forensic_analysis_logs && Object.keys(data.forensic_analysis_logs).length > 0) { | |
| logsHtml = ` | |
| <div class="forensic-logs-detail" style="margin-top: 15px; margin-bottom: 15px; text-align: left;"> | |
| <button class="btn-refresh" style="width: 100%; text-align: left; padding: 10px 15px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,215,0,0.25); border-radius: 8px; color: var(--yellow-main); cursor: pointer; font-size: 13px; display: flex; justify-content: space-between; align-items: center; margin-bottom: 0;" onclick="toggleForensicLogs(this)"> | |
| <span>π Lihat Log Detil Forensik (12 Langkah)</span> | |
| <span style="font-size:10px">βΌ</span> | |
| </button> | |
| <div class="logs-content hidden" style="background: rgba(0,0,0,0.5); border: 1px solid rgba(255,215,0,0.15); border-top: none; border-radius: 0 0 8px 8px; padding: 15px; font-family: monospace; font-size: 12px; color: #2ed573; line-height: 1.6; max-height: 250px; overflow-y: auto;"> | |
| `; | |
| for (let i = 1; i <= 12; i++) { | |
| const stepText = data.forensic_analysis_logs[`step_${i}`] || `[Step ${i}/12] Analisis selesai.`; | |
| logsHtml += `<div style="margin-bottom: 6px; border-bottom: 1px dashed rgba(255,255,255,0.05); padding-bottom: 4px;">${stepText}</div>`; | |
| } | |
| logsHtml += ` | |
| </div> | |
| </div> | |
| `; | |
| } | |
| resultBox.className = `result-box ${statusClass}`; | |
| resultBox.innerHTML = ` | |
| <div class="result-title" style="color: ${statusColor}"> | |
| <span>${statusText}</span> | |
| <span>Akurasi: ${data.accuracy}%</span> | |
| </div> | |
| <div class="badge-bar" style="display:flex;gap:8px;margin-bottom:15px;flex-wrap:wrap"> | |
| <span class="badge" style="background:rgba(255,215,0,0.1);color:var(--yellow-main);border:1px solid rgba(255,215,0,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px"> | |
| 𧬠Cosine Similarity: ${(data.similarity * 100).toFixed(1)}% | |
| </span> | |
| ${data.is_outlier | |
| ? `<span class="badge" style="background:rgba(255,71,87,0.1);color:var(--danger);border:1px solid rgba(255,71,87,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">π‘ Data Asing (Outlier)</span>` | |
| : `<span class="badge" style="background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">π― Klasifikasi Aman</span>` | |
| } | |
| ${data.is_trap | |
| ? `<span class="badge" style="background:rgba(230,126,34,0.15);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">β οΈ Trap Image Mode</span>` | |
| : '' | |
| } | |
| ${data.is_dark | |
| ? `<span class="badge" style="background:rgba(230,126,34,0.1);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">π Low Light (Cahaya Rendah)</span>` | |
| : '' | |
| } | |
| ${data.is_grayscale | |
| ? `<span class="badge" style="background:rgba(149,165,166,0.15);color:#bdc3c7;border:1px solid rgba(149,165,166,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">βͺ Monokrom (Hitam Putih)</span>` | |
| : '' | |
| } | |
| </div> | |
| ${(data.is_dark || data.is_grayscale) | |
| ? ` | |
| <div style="background: rgba(230,126,34,0.1); border: 1px dashed rgba(230,126,34,0.3); border-radius: 8px; padding: 12px; margin-bottom: 15px; font-size: 12px; color: #f39c12; line-height: 1.5; text-align: left;"> | |
| <b>β οΈ Rekomendasi Kondisi Deteksi:</b><br/> | |
| ${data.is_dark ? 'β’ Cahaya terdeteksi rendah (kecerahan rata-rata: ' + data.avg_brightness + '/255). Hal ini memicu noise sensor kamera yang dapat mengganggu keakuratan forensik AI.<br/>' : ''} | |
| ${data.is_grayscale ? 'β’ Gambar monokrom/hitam-putih terdeteksi. Kehilangan informasi kromatik (saluran warna RGB) secara drastis dapat menurunkan performa klasifikasi model AI.<br/>' : ''} | |
| <i style="display:block;margin-top:6px;color:rgba(255,255,255,0.7)">Disarankan untuk melakukan scan ulang menggunakan foto dengan pencahayaan cukup and penuh warna (Full RGB).</i> | |
| </div> | |
| ` | |
| : '' | |
| } | |
| ${logsHtml} | |
| <div class="details-grid"> | |
| <div class="detail-item"> | |
| <div class="detail-label">Nama File</div> | |
| <div class="detail-value">${data.filename}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Tipe</div> | |
| <div class="detail-value">${data.type.toUpperCase()}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Ukuran File</div> | |
| <div class="detail-value">${data.file_size}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Sumber Deteksi</div> | |
| <div class="detail-value" style="font-size:12px">${data.source}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Tanggal Scan</div> | |
| <div class="detail-value">${data.date}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Keakuratan</div> | |
| <div class="detail-value" style="color: ${statusColor}">${data.accuracy}%</div> | |
| </div> | |
| </div> | |
| <div style="margin-top:15px;padding-top:15px;border-top:1px solid rgba(255,255,255,0.2)"> | |
| <p style="margin-bottom:10px;color:var(--yellow-main)">Apakah hasil ini benar?</p> | |
| <button class="btn-scan" style="padding:8px 25px;font-size:14px;margin-right:10px" onclick="confirmSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">β Benar</button> | |
| <button class="btn-scan" style="padding:8px 25px;font-size:14px" onclick="correctSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">β Salah</button> | |
| <div id="single-correction-area" style="margin-top:10px"></div> | |
| </div> | |
| `; | |
| if (currentUser !== "__guest__") { | |
| updateUserTrustScoreUI(data.trust_score || 50); | |
| } | |
| } else { | |
| resultBox.innerHTML = `<h3 style="color: var(--danger)">Error: ${data.detail}</h3>`; | |
| } | |
| } catch (err) { | |
| resultBox.innerHTML = "<h3 style='color: var(--danger)'>Gagal terhubung ke server Backend.</h3>"; | |
| } | |
| } | |
| function confirmSingleResult(filename, prediction, accuracy, feedbackId, btn) { | |
| fetch(`${API_URL}/api/correction-single`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| username: currentUser, | |
| filename: filename, | |
| original_prediction: prediction, | |
| correct_label: prediction, | |
| confidence: parseFloat(accuracy), | |
| feedback_id: feedbackId | |
| }) | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| updateGlobalStats(); | |
| if (data.is_trap) { | |
| if (currentUser !== "__guest__") { | |
| updateUserTrustScoreUI(data.new_trust); | |
| const scoreDiff = data.trust_change > 0 ? `+${data.trust_change}` : `${data.trust_change}`; | |
| const statusIcon = data.trap_correct ? "π BENAR!" : "β SALAH!"; | |
| alert(`π‘οΈ TRAP IMAGE DETECTED!\nFeedback Anda ${statusIcon}\nSkor Kredibilitas Anda: ${scoreDiff} (Sekarang: ${data.new_trust}/100)`); | |
| } else { | |
| alert(`π‘οΈ TRAP IMAGE DETECTED! (Mode Tamu)`); | |
| } | |
| } | |
| }) | |
| .catch(() => { }); | |
| btn.closest("div").innerHTML = "<p style='color:var(--success)'>β Konfirmasi tersimpan!</p>"; | |
| } | |
| function correctSingleResult(filename, prediction, accuracy, feedbackId, btn) { | |
| const correctLabel = prediction === "AI" ? "REAL" : "AI"; | |
| fetch(`${API_URL}/api/correction-single`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| username: currentUser, | |
| filename: filename, | |
| original_prediction: prediction, | |
| correct_label: correctLabel, | |
| confidence: parseFloat(accuracy), | |
| feedback_id: feedbackId | |
| }) | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| updateGlobalStats(); | |
| if (data.is_trap) { | |
| if (currentUser !== "__guest__") { | |
| updateUserTrustScoreUI(data.new_trust); | |
| const scoreDiff = data.trust_change > 0 ? `+${data.trust_change}` : `${data.trust_change}`; | |
| const statusIcon = data.trap_correct ? "π BENAR!" : "β SALAH!"; | |
| alert(`π‘οΈ TRAP IMAGE DETECTED!\nFeedback Anda ${statusIcon}\nSkor Kredibilitas Anda: ${scoreDiff} (Sekarang: ${data.new_trust}/100)`); | |
| } else { | |
| alert(`π‘οΈ TRAP IMAGE DETECTED! (Mode Tamu)`); | |
| } | |
| } | |
| }) | |
| .catch(() => { }); | |
| btn.closest("div").innerHTML = `<p style='color:var(--success)'>β Koreksi tersimpan! (seharusnya ${correctLabel})</p>`; | |
| } | |
| // --- 7. BATCH TEST LOGIC --- | |
| let batchFiles = []; | |
| let batchLabels = []; | |
| document.getElementById("input-batch").addEventListener("change", (e) => { | |
| const files = e.target.files; | |
| if (!files.length) return; | |
| const folderName = files[0].webkitRelativePath.split('/')[0]; | |
| document.getElementById("batch-folder-name").innerText = folderName; | |
| batchFiles = []; | |
| batchLabels = []; | |
| const listDiv = document.getElementById("batch-file-list"); | |
| listDiv.innerHTML = "<h4 style='margin-bottom:10px'>File ditemukan:</h4>"; | |
| const table = document.createElement("table"); | |
| table.className = "history-table"; | |
| table.innerHTML = `<thead><tr><th>File</th><th>Folder</th></tr></thead><tbody></tbody>`; | |
| const tbody = table.querySelector("tbody"); | |
| let hasValidSubfolders = false; | |
| for (let f of files) { | |
| const parts = f.webkitRelativePath.split('/'); | |
| const label = parts.length > 1 ? parts[parts.length - 2] : ""; | |
| if (!f.name.match(/\.(png|jpg|jpeg|webp)$/i)) continue; | |
| // Normalize folder label for display consistency (Poin 2) | |
| const labelUpper = label.toUpperCase(); | |
| let normLabel = ""; | |
| if (labelUpper === "FAKE" || labelUpper === "AI") { | |
| normLabel = "AI"; | |
| hasValidSubfolders = true; | |
| } else if (labelUpper === "REAL") { | |
| normLabel = "REAL"; | |
| hasValidSubfolders = true; | |
| } | |
| batchFiles.push(f); | |
| batchLabels.push(normLabel); | |
| const tr = document.createElement("tr"); | |
| const color = normLabel === "REAL" ? "var(--success)" : | |
| (normLabel === "AI") ? "var(--danger)" : "rgba(255,255,255,0.4)"; | |
| tr.innerHTML = `<td>${f.name}</td><td style="color:${color};font-weight:bold">${normLabel || "-"}</td>`; | |
| tbody.appendChild(tr); | |
| } | |
| if (currentUser !== "Sandikad" && !hasValidSubfolders) { | |
| alert("Akses Ditolak: Hanya pengguna 'Sandikad' yang diizinkan melakukan Batch Scan tanpa struktur subfolder (real/fake).\nSilakan unggah folder yang berisi subfolder 'real' dan 'fake/ai'."); | |
| batchFiles = []; | |
| batchLabels = []; | |
| document.getElementById("batch-folder-name").innerText = "Belum ada folder dipilih"; | |
| listDiv.innerHTML = ""; | |
| return; | |
| } | |
| listDiv.appendChild(table); | |
| listDiv.innerHTML += `<p style="margin-top:10px;color:var(--yellow-main)">Total: <b>${batchFiles.length}</b> gambar</p>`; | |
| }); | |
| async function startBatchScan() { | |
| if (!batchFiles.length) return alert("Pilih folder terlebih dahulu!"); | |
| if (!currentUser) return alert("Login dulu!"); | |
| let hasValidSubfolders = batchLabels.some(l => l === "REAL" || l === "AI"); | |
| if (currentUser !== "Sandikad" && !hasValidSubfolders) { | |
| alert("Akses Ditolak: Hanya pengguna 'Sandikad' yang diizinkan melakukan Batch Scan tanpa struktur subfolder (real/fake)."); | |
| return; | |
| } | |
| const progress = document.getElementById("batch-progress"); | |
| const resultDiv = document.getElementById("batch-result"); | |
| const confirmArea = document.getElementById("batch-confirm-area"); | |
| progress.classList.remove("hidden"); | |
| resultDiv.classList.add("hidden"); | |
| if (confirmArea) confirmArea.classList.add("hidden"); | |
| progress.innerHTML = "β³ Mengirim file ke server..."; | |
| const formData = new FormData(); | |
| for (let f of batchFiles) { | |
| formData.append("files", f); | |
| } | |
| formData.append("username", currentUser); | |
| // Convert to robust filename -> label map to prevent any index alignment shifts | |
| const labelMap = {}; | |
| for (let i = 0; i < batchFiles.length; i++) { | |
| labelMap[batchFiles[i].name] = batchLabels[i]; | |
| } | |
| formData.append("labels", JSON.stringify(labelMap)); | |
| try { | |
| const res = await fetch(`${API_URL}/api/batch-scan`, { method: "POST", body: formData }); | |
| const data = await res.json(); | |
| progress.classList.add("hidden"); | |
| if (!res.ok) { | |
| resultDiv.innerHTML = `<h3 style="color:var(--danger)">Error: ${data.detail}</h3>`; | |
| resultDiv.classList.remove("hidden"); | |
| return; | |
| } | |
| displayBatchResults(data, resultDiv, confirmArea); | |
| updateGlobalStats(); | |
| } catch (err) { | |
| progress.innerHTML = "<h3 style='color:var(--danger)'>Gagal terhubung ke server.</h3>"; | |
| } | |
| } | |
| function displayBatchResults(data, resultDiv, confirmArea) { | |
| const isUnlabeled = data.accuracy === -1; | |
| const color = isUnlabeled ? "var(--yellow-main)" : (data.accuracy >= 70 ? "var(--success)" : data.accuracy >= 40 ? "var(--blue-dark)" : "var(--danger)"); | |
| let html = ` | |
| <div class="result-box" style="border-color:${color}"> | |
| <div class="result-title" style="color:${color}; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;"> | |
| <div> | |
| <span>${isUnlabeled ? "Hasil Batch Scan" : "Hasil Batch Test"}</span> | |
| <span style="font-size: 12px; background: rgba(255, 215, 0, 0.15); color: var(--yellow-main); padding: 4px 8px; border-radius: 6px; margin-left: 10px; font-weight: normal; border: 1px solid rgba(255, 215, 0, 0.3)">Using Threshold: 0.50</span> | |
| </div> | |
| <span>Akurasi: ${isUnlabeled ? "-" : data.accuracy + "%"}</span> | |
| </div> | |
| <div class="details-grid"> | |
| <div class="detail-item"> | |
| <div class="detail-label">Total Gambar</div> | |
| <div class="detail-value">${data.total}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Benar</div> | |
| <div class="detail-value" style="color:var(--success)">${isUnlabeled ? "-" : data.correct}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Salah</div> | |
| <div class="detail-value" style="color:var(--danger)">${isUnlabeled ? "-" : data.wrong}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Akurasi</div> | |
| <div class="detail-value" style="color:${color}">${isUnlabeled ? "-" : data.accuracy + "%"}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="table-wrap" style="margin-top:15px"> | |
| <table class="history-table"> | |
| <thead><tr><th>File</th><th>Folder</th><th>Prediksi</th><th>Confidence</th><th>Status</th></tr></thead> | |
| <tbody>`; | |
| data.results.forEach((r, i) => { | |
| if (r.error) { | |
| html += `<tr><td>${r.filename}</td><td colspan="4" style="color:var(--danger)">Error: ${r.error}</td></tr>`; | |
| return; | |
| } | |
| const statusColor = r.folder_label ? (r.is_mismatch ? "var(--danger)" : "var(--success)") : "rgba(255, 255, 255, 0.4)"; | |
| const statusText = r.folder_label ? (r.is_mismatch ? "β SALAH" : "β BENAR") : "-"; | |
| // Capitalize folder label consistently (Poin 2) | |
| const folderUpper = r.folder_label ? r.folder_label.toUpperCase() : "-"; | |
| const folderColor = folderUpper === "REAL" ? "var(--success)" : | |
| (folderUpper === "AI" || folderUpper === "FAKE") ? "var(--danger)" : "rgba(255, 255, 255, 0.4)"; | |
| const predColor = r.prediction === "REAL" ? "var(--success)" : "var(--danger)"; | |
| // Highlight incorrect rows (Poin 3) | |
| const rowBg = r.is_mismatch ? "background: rgba(255, 71, 87, 0.08);" : ""; | |
| html += `<tr style="${rowBg}"> | |
| <td>${r.filename}</td> | |
| <td style="color:${folderColor};font-weight:bold">${folderUpper}</td> | |
| <td style="color:${predColor};font-weight:bold">${r.prediction}</td> | |
| <td style="color:var(--yellow-main)">${r.confidence}%</td> | |
| <td style="color:${statusColor};font-weight:bold">${statusText}</td> | |
| </tr>`; | |
| }); | |
| html += `</tbody></table></div>`; | |
| html += `<div style="margin-top:15px;text-align:center"><button class="btn-scan" onclick="showSection('accuracy');event.target.closest('#batch-result .btn-scan').remove()" style="padding:10px 30px;font-size:14px">π Lihat Akurasi β</button></div>`; | |
| resultDiv.innerHTML = html; | |
| resultDiv.classList.remove("hidden"); | |
| } | |
| // --- 7. ACCURACY REPORT --- | |
| async function loadAccuracyReport() { | |
| if (!currentUser) return; | |
| const summaryDiv = document.getElementById("accuracy-summary"); | |
| const timeFilter = document.getElementById("accuracy-time-filter")?.value || "all"; | |
| try { | |
| const res = await fetch(`${API_URL}/api/accuracy-report?filter=${timeFilter}`); | |
| const report = await res.json(); | |
| const s = report.stats; | |
| const matrix = report.confusion_matrix; | |
| const dist = report.confidence_distribution; | |
| const failures = report.failures; | |
| const textColor = s.accuracy >= 70 ? "var(--success)" : s.accuracy >= 40 ? "var(--yellow-main)" : "var(--danger)"; | |
| // 1. Render Summary stats & Advanced metrics | |
| summaryDiv.innerHTML = ` | |
| <!-- Primary Stats --> | |
| <div class="stats-grid"> | |
| <div class="stat-card blue"><h3>${s.total}</h3><p>Total Test</p></div> | |
| <div class="stat-card yellow"><h3>${s.correct}</h3><p>Benar</p></div> | |
| <div class="stat-card blue"><h3>${s.wrong}</h3><p>Salah</p></div> | |
| <div class="stat-card yellow"><h3 style="color:${textColor}">${s.accuracy}%</h3><p>Akurasi</p></div> | |
| </div> | |
| <!-- Advanced ML Metrics --> | |
| <div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); margin-top: 15px;"> | |
| <div class="stat-card" style="background: #002244 !important; border: 1px solid rgba(255, 215, 0, 0.35) !important; border-left: 4px solid var(--yellow-main) !important; padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25) !important;"> | |
| <h3 style="font-size: 24px; color: #FFD700 !important; font-weight: 800; margin: 0; opacity: 1 !important;">${s.precision}%</h3> | |
| <p style="font-size: 11px; margin: 6px 0 0 0; color: #ffffff !important; opacity: 1 !important; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">Precision (Presisi)</p> | |
| </div> | |
| <div class="stat-card" style="background: #002244 !important; border: 1px solid rgba(255, 215, 0, 0.35) !important; border-left: 4px solid var(--yellow-main) !important; padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25) !important;"> | |
| <h3 style="font-size: 24px; color: #FFD700 !important; font-weight: 800; margin: 0; opacity: 1 !important;">${s.recall}%</h3> | |
| <p style="font-size: 11px; margin: 6px 0 0 0; color: #ffffff !important; opacity: 1 !important; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">Recall (Sensitivitas)</p> | |
| </div> | |
| <div class="stat-card" style="background: #002244 !important; border: 1px solid rgba(255, 215, 0, 0.35) !important; border-left: 4px solid var(--yellow-main) !important; padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25) !important;"> | |
| <h3 style="font-size: 24px; color: #FFD700 !important; font-weight: 800; margin: 0; opacity: 1 !important;">${s.f1_score}%</h3> | |
| <p style="font-size: 11px; margin: 6px 0 0 0; color: #ffffff !important; opacity: 1 !important; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">F1-Score (Harmonis)</p> | |
| </div> | |
| </div> | |
| <!-- Info Box --> | |
| <div class="info-box" style="margin-top: 15px;"> | |
| <p>Batch test: <b style="color:var(--yellow-main)">${report.batch_images}</b> gambar</p> | |
| <p>Scan individu: <b style="color:var(--yellow-main)">${report.scan_count}</b> gambar</p> | |
| <p>Total data pembelajaran: <b style="color:var(--yellow-main)">${report.learning_data_count}</b> gambar (siap training)</p> | |
| </div> | |
| `; | |
| // 2. Render Confusion Matrix Values | |
| document.getElementById("cm-tp").innerHTML = `${matrix.tp}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">TP (True AI)</span>`; | |
| document.getElementById("cm-fp").innerHTML = `${matrix.fp}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">FP (False AI)</span>`; | |
| document.getElementById("cm-fn").innerHTML = `${matrix.fn}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">FN (False Real)</span>`; | |
| document.getElementById("cm-tn").innerHTML = `${matrix.tn}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">TN (True Real)</span>`; | |
| // 3. Render Failure Log Table | |
| const failureBody = document.getElementById("failure-log-body"); | |
| failureBody.innerHTML = ""; | |
| if (failures.length === 0) { | |
| failureBody.innerHTML = "<tr><td colspan='5' style='text-align:center;padding:20px;color:rgba(255,255,255,0.3)'>Tidak ada kesalahan tebak terdeteksi dalam data pengetesan ini! π</td></tr>"; | |
| } else { | |
| failures.forEach(f => { | |
| const expColor = f.expected === 'AI' ? 'var(--danger)' : 'var(--success)'; | |
| const predColor = f.prediction === 'AI' ? 'var(--danger)' : 'var(--success)'; | |
| failureBody.innerHTML += ` | |
| <tr> | |
| <td>${f.filename}</td> | |
| <td><span style="color: ${expColor}; font-weight:bold">${f.expected}</span></td> | |
| <td><span style="color: ${predColor}">${f.prediction}</span></td> | |
| <td style="color: var(--yellow-main); font-weight:bold">${f.confidence}%</td> | |
| <td style="font-size: 11px; opacity: 0.7;">${f.date}</td> | |
| </tr> | |
| `; | |
| }); | |
| } | |
| // 4. Render Charts | |
| drawDonutChart(s.correct, s.wrong); | |
| drawBarChart(report.batches); | |
| drawConfidenceChart(dist); | |
| } catch (err) { | |
| console.error("Gagal memuat accuracy report:", err); | |
| } | |
| } | |
| let chartDonut = null, chartBar = null, chartConfidence = null; | |
| function drawDonutChart(correct, wrong) { | |
| const ctx = document.getElementById("chart-donut").getContext("2d"); | |
| if (chartDonut) chartDonut.destroy(); | |
| if (correct + wrong === 0) return; | |
| chartDonut = new Chart(ctx, { | |
| type: "doughnut", | |
| data: { | |
| labels: ["Benar", "Salah"], | |
| datasets: [{ | |
| data: [correct, wrong], | |
| backgroundColor: ["#2ed573", "#ff4757"], | |
| borderWidth: 0 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { | |
| title: { display: true, text: "Perbandingan Benar vs Salah", color: "#FFD700" }, | |
| legend: { labels: { color: "#fff" } } | |
| } | |
| } | |
| }); | |
| } | |
| function drawBarChart(batches) { | |
| const ctx = document.getElementById("chart-bar").getContext("2d"); | |
| if (chartBar) chartBar.destroy(); | |
| if (!batches.length) return; | |
| chartBar = new Chart(ctx, { | |
| type: "bar", | |
| data: { | |
| labels: batches.slice(0, 10).map(b => "#" + b.id), // limit to latest 10 batches | |
| datasets: [ | |
| { label: "Benar", data: batches.slice(0, 10).map(b => b.correct_count), backgroundColor: "#2ed573" }, | |
| { label: "Salah", data: batches.slice(0, 10).map(b => b.wrong_count), backgroundColor: "#ff4757" } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| scales: { | |
| x: { ticks: { color: "#fff" }, stacked: true }, | |
| y: { ticks: { color: "#fff" }, stacked: true } | |
| }, | |
| plugins: { | |
| title: { display: true, text: "Akurasi per Batch Test (10 Terakhir)", color: "#FFD700" }, | |
| legend: { labels: { color: "#fff" } } | |
| } | |
| } | |
| }); | |
| } | |
| function drawConfidenceChart(dist) { | |
| const ctx = document.getElementById("chart-confidence").getContext("2d"); | |
| if (chartConfidence) chartConfidence.destroy(); | |
| chartConfidence = new Chart(ctx, { | |
| type: "line", | |
| data: { | |
| labels: dist.buckets, | |
| datasets: [ | |
| { | |
| label: "REAL Predictions", | |
| data: dist.real, | |
| borderColor: "#2ed573", | |
| backgroundColor: "rgba(46, 213, 115, 0.1)", | |
| fill: true, | |
| tension: 0.4 | |
| }, | |
| { | |
| label: "AI Predictions", | |
| data: dist.ai, | |
| borderColor: "#ff4757", | |
| backgroundColor: "rgba(255, 71, 87, 0.1)", | |
| fill: true, | |
| tension: 0.4 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| scales: { | |
| x: { ticks: { color: "#fff" }, grid: { color: "rgba(255,255,255,0.05)" } }, | |
| y: { ticks: { color: "#fff" }, grid: { color: "rgba(255,255,255,0.05)" }, beginAtZero: true } | |
| }, | |
| plugins: { | |
| title: { display: true, text: "Sebaran Skor Keyakinan (Confidence)", color: "#FFD700" }, | |
| legend: { labels: { color: "#fff" } } | |
| } | |
| } | |
| }); | |
| } | |
| async function confirmClearHistory() { | |
| if (!confirm("β οΈ PERINGATAN: Apakah Anda yakin ingin menghapus seluruh riwayat scan dan merestart semua statistik pengujian kembali ke angka nol? Tindakan ini tidak dapat dibatalkan!")) { | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`${API_URL}/api/clear-history`); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| alert("Statistik berhasil direset ke nol!"); | |
| loadAccuracyReport(); | |
| loadHistory(); | |
| updateGlobalStats(); | |
| } | |
| } catch (e) { | |
| alert("Gagal mereset statistik."); | |
| } | |
| } | |
| function downloadFeedback() { | |
| if (currentUser !== "Sandikad") { | |
| alert("Hanya user Sandikad yang dapat mendownload feedback."); | |
| return; | |
| } | |
| window.open(`${API_URL}/api/download-feedback?username=${currentUser}`, "_blank"); | |
| } | |
| async function loadHistory() { | |
| if (!currentUser) return; | |
| const tbody = document.getElementById("history-body"); | |
| const count = document.getElementById("history-count"); | |
| tbody.innerHTML = "<tr><td colspan='7' style='text-align:center;padding:25px;color:rgba(255,255,255,0.3)'>Loading...</td></tr>"; | |
| try { | |
| const res = await fetch(`${API_URL}/api/history/${currentUser}`); | |
| const data = await res.json(); | |
| tbody.innerHTML = ""; | |
| count.innerText = `${data.history.length} item`; | |
| data.history.forEach(item => { | |
| if (item._type === "batch") { | |
| const batchColor = item.accuracy >= 70 ? "var(--success)" : item.accuracy >= 40 ? "var(--yellow-main)" : "var(--danger)"; | |
| tbody.innerHTML += ` | |
| <tr style="background:rgba(255,215,0,0.05)"> | |
| <td style="color:var(--yellow-main)">${item.filename}</td> | |
| <td>${item.file_type}</td> | |
| <td>${item.file_size}</td> | |
| <td style="font-size:12px">${item.source}</td> | |
| <td style="color: ${batchColor}; font-weight:bold">${item.accuracy}%</td> | |
| <td style="color: ${batchColor}; font-weight:bold">${item.accuracy >= 70 ? 'β Baik' : item.accuracy >= 40 ? 'β οΈ Sedang' : 'β Buruk'}</td> | |
| <td>${item.scan_date}</td> | |
| </tr>`; | |
| } else { | |
| const statusColor = item.is_ai ? "var(--danger)" : "var(--success)"; | |
| const statusText = item.is_ai ? "Palsu (AI)" : "Asli (Real)"; | |
| tbody.innerHTML += ` | |
| <tr> | |
| <td>${item.filename}</td> | |
| <td>${item.file_type}</td> | |
| <td>${item.file_size}</td> | |
| <td style="font-size:12px">${item.source}</td> | |
| <td style="color: var(--yellow-main); font-weight:bold">${item.accuracy}%</td> | |
| <td style="color: ${statusColor}; font-weight:bold">${statusText}</td> | |
| <td>${item.scan_date}</td> | |
| </tr> | |
| `; | |
| } | |
| }); | |
| if (data.history.length === 0) tbody.innerHTML = "<tr><td colspan='7' style='text-align:center;padding:30px;color:rgba(255,255,255,0.3)'>Belum ada history. Scan gambar atau jalankan batch test.</td></tr>"; | |
| } catch (err) { | |
| tbody.innerHTML = "<tr><td colspan='7' style='text-align:center;padding:30px;color:var(--danger)'>Gagal memuat data.</td></tr>"; | |
| } | |
| } | |
| function startGuestMode(e) { | |
| if (e) e.preventDefault(); | |
| currentUser = "__guest__"; | |
| document.getElementById("user-name").innerText = "Guest"; | |
| // Sembunyikan skor kredibilitas & tampilkan sisa scan gratis | |
| document.getElementById("user-trust-container").style.display = "none"; | |
| const limitContainer = document.getElementById("guest-scan-container"); | |
| if (limitContainer) { | |
| limitContainer.style.display = "flex"; | |
| updateGuestScanCountUI(); | |
| } | |
| // Batasi akses sidebar hanya ke Dashboard dan Scan Gambar | |
| document.getElementById("menu-batch-test").style.display = "none"; | |
| document.getElementById("menu-history").style.display = "none"; | |
| document.getElementById("menu-accuracy").style.display = "none"; | |
| // Transisi ke aplikasi utama | |
| document.getElementById("auth-page").classList.add("hidden"); | |
| document.getElementById("main-app").classList.remove("hidden"); | |
| document.getElementById("app-style").href = "style.css"; | |
| // Tampilkan bagian dashboard secara default | |
| showSection("dashboard"); | |
| loadDashboard(); | |
| } | |
| function updateGuestScanCountUI() { | |
| let count = parseInt(localStorage.getItem("guest_scan_count") || "0"); | |
| const remaining = Math.max(0, 5 - count); | |
| const remEl = document.getElementById("guest-scan-remaining"); | |
| if (remEl) { | |
| remEl.innerText = remaining; | |
| } | |
| const landingQuotaEl = document.getElementById("landing-quota-left"); | |
| if (landingQuotaEl) { | |
| landingQuotaEl.innerText = remaining; | |
| } | |
| } | |
| // --- AUTH MODAL LOGIC --- | |
| function openAuthModal() { | |
| document.getElementById("auth-modal").classList.remove("hidden"); | |
| } | |
| function closeAuthModal() { | |
| document.getElementById("auth-modal").classList.add("hidden"); | |
| } | |
| function closeAuthModalOnOverlay(e) { | |
| if (e.target === document.getElementById("auth-modal")) { | |
| closeAuthModal(); | |
| } | |
| } | |
| // --- LANDING PAGE FILE SCANNING LOGIC --- | |
| document.getElementById("landing-input-image").addEventListener("change", (e) => { | |
| document.getElementById("landing-img-name").innerText = e.target.files[0]?.name || "Tidak ada file dipilih"; | |
| }); | |
| async function scanLandingFile() { | |
| const fileInput = document.getElementById("landing-input-image"); | |
| const resultBox = document.getElementById("landing-result-image"); | |
| let count = parseInt(localStorage.getItem("guest_scan_count") || "0"); | |
| if (count >= 5) { | |
| alert("Batas scan gratis Anda (5 kali) sudah habis!\nSilakan Login atau Register terlebih dahulu untuk scan tanpa batas."); | |
| openAuthModal(); | |
| return; | |
| } | |
| if (!fileInput.files[0]) return alert("Pilih file terlebih dahulu!"); | |
| const file = fileInput.files[0]; | |
| if (file.size > 10 * 1024 * 1024) return alert("Ukuran file melebihi batas 10MB!"); | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| formData.append("username", "__guest__"); | |
| const fetchPromise = fetch(`${API_URL}/api/scan-image`, { method: "POST", body: formData }); | |
| await simulateForensicAnalysis(resultBox, fetchPromise); | |
| try { | |
| const res = await fetchPromise; | |
| const data = await res.json(); | |
| // CEK OTOMATIS GAMBAR HITAM PUTIH UNTUK TAMPILKAN BANNER ALERT | |
| const warningBanner = document.getElementById("landing-warning-banner-monokrom"); | |
| if (warningBanner) { | |
| if (data.is_monochrome_detected === true) { | |
| warningBanner.style.display = "block"; | |
| } else { | |
| warningBanner.style.display = "none"; | |
| } | |
| } | |
| if (res.ok) { | |
| let count = parseInt(localStorage.getItem("guest_scan_count") || "0"); | |
| count++; | |
| localStorage.setItem("guest_scan_count", count.toString()); | |
| updateGuestScanCountUI(); | |
| const statusClass = data.is_ai ? "ai" : "real"; | |
| const statusText = data.is_ai ? "β οΈ GAMBAR PALSU (AI)" : "β GAMBAR ASLI (REAL)"; | |
| const statusColor = data.is_ai ? "var(--danger)" : "var(--success)"; | |
| let logsHtml = ''; | |
| if (data.forensic_analysis_logs && Object.keys(data.forensic_analysis_logs).length > 0) { | |
| logsHtml = ` | |
| <div class="forensic-logs-detail" style="margin-top: 15px; margin-bottom: 15px; text-align: left;"> | |
| <button class="btn-refresh" style="width: 100%; text-align: left; padding: 10px 15px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,215,0,0.25); border-radius: 8px; color: var(--yellow-main); cursor: pointer; font-size: 13px; display: flex; justify-content: space-between; align-items: center; margin-bottom: 0;" onclick="toggleForensicLogs(this)"> | |
| <span>π Lihat Log Detil Forensik (12 Langkah)</span> | |
| <span style="font-size:10px">βΌ</span> | |
| </button> | |
| <div class="logs-content hidden" style="background: rgba(0,0,0,0.5); border: 1px solid rgba(255,215,0,0.15); border-top: none; border-radius: 0 0 8px 8px; padding: 15px; font-family: monospace; font-size: 12px; color: #2ed573; line-height: 1.6; max-height: 250px; overflow-y: auto;"> | |
| `; | |
| for (let i = 1; i <= 12; i++) { | |
| const stepText = data.forensic_analysis_logs[`step_${i}`] || `[Step ${i}/12] Analisis selesai.`; | |
| logsHtml += `<div style="margin-bottom: 6px; border-bottom: 1px dashed rgba(255,255,255,0.05); padding-bottom: 4px;">${stepText}</div>`; | |
| } | |
| logsHtml += ` | |
| </div> | |
| </div> | |
| `; | |
| } | |
| resultBox.className = `result-box ${statusClass}`; | |
| resultBox.innerHTML = ` | |
| <div class="result-title" style="color: ${statusColor}"> | |
| <span>${statusText}</span> | |
| <span>Akurasi: ${data.accuracy}%</span> | |
| </div> | |
| <div class="badge-bar" style="display:flex;gap:8px;margin-bottom:15px;flex-wrap:wrap"> | |
| <span class="badge" style="background:rgba(255,215,0,0.1);color:var(--yellow-main);border:1px solid rgba(255,215,0,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px"> | |
| 𧬠Cosine Similarity: ${(data.similarity * 100).toFixed(1)}% | |
| </span> | |
| ${data.is_outlier | |
| ? `<span class="badge" style="background:rgba(255,71,87,0.1);color:var(--danger);border:1px solid rgba(255,71,87,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">π‘ Data Asing (Outlier)</span>` | |
| : `<span class="badge" style="background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">π― Klasifikasi Aman</span>` | |
| } | |
| ${data.is_trap | |
| ? `<span class="badge" style="background:rgba(230,126,34,0.15);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">β οΈ Trap Image Mode</span>` | |
| : '' | |
| } | |
| ${data.is_dark | |
| ? `<span class="badge" style="background:rgba(230,126,34,0.1);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">π Low Light (Cahaya Rendah)</span>` | |
| : '' | |
| } | |
| ${data.is_grayscale | |
| ? `<span class="badge" style="background:rgba(149,165,166,0.15);color:#bdc3c7;border:1px solid rgba(149,165,166,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">βͺ Monokrom (Hitam Putih)</span>` | |
| : '' | |
| } | |
| </div> | |
| ${(data.is_dark || data.is_grayscale) | |
| ? ` | |
| <div style="background: rgba(230,126,34,0.1); border: 1px dashed rgba(230,126,34,0.3); border-radius: 8px; padding: 12px; margin-bottom: 15px; font-size: 12px; color: #f39c12; line-height: 1.5; text-align: left;"> | |
| <b>β οΈ Rekomendasi Kondisi Deteksi:</b><br/> | |
| ${data.is_dark ? 'β’ Cahaya terdeteksi rendah (kecerahan rata-rata: ' + data.avg_brightness + '/255). Hal ini memicu noise sensor kamera yang dapat mengganggu keakuratan forensik AI.<br/>' : ''} | |
| ${data.is_grayscale ? 'β’ Gambar monokrom/hitam-putih terdeteksi. Kehilangan informasi kromatik (saluran warna RGB) secara drastis dapat menurunkan performa klasifikasi model AI.<br/>' : ''} | |
| <i style="display:block;margin-top:6px;color:rgba(255,255,255,0.7)">Disarankan untuk melakukan scan ulang menggunakan foto dengan pencahayaan cukup dan penuh warna (Full RGB).</i> | |
| </div> | |
| ` | |
| : '' | |
| } | |
| ${logsHtml} | |
| <div class="details-grid"> | |
| <div class="detail-item"> | |
| <div class="detail-label">Nama File</div> | |
| <div class="detail-value">${data.filename}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Tipe</div> | |
| <div class="detail-value">${data.type.toUpperCase()}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Ukuran File</div> | |
| <div class="detail-value">${data.file_size}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Sumber Deteksi</div> | |
| <div class="detail-value" style="font-size:12px">${data.source}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Tanggal Scan</div> | |
| <div class="detail-value">${data.date}</div> | |
| </div> | |
| <div class="detail-item"> | |
| <div class="detail-label">Keakuratan</div> | |
| <div class="detail-value" style="color: ${statusColor}">${data.accuracy}%</div> | |
| </div> | |
| </div> | |
| <div style="margin-top:15px;padding-top:15px;border-top:1px solid rgba(255,255,255,0.2)"> | |
| <p style="margin-bottom:10px;color:var(--yellow-main)">Apakah hasil ini benar?</p> | |
| <button class="btn-scan" style="padding:8px 25px;font-size:14px;margin-right:10px" onclick="confirmSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">β Benar</button> | |
| <button class="btn-scan" style="padding:8px 25px;font-size:14px" onclick="correctSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">β Salah</button> | |
| <div id="single-correction-area" style="margin-top:10px"></div> | |
| </div> | |
| `; | |
| updateGlobalStats(); | |
| } else { | |
| resultBox.innerHTML = `<h3 style="color: var(--danger)">Error: ${data.detail}</h3>`; | |
| } | |
| } catch (err) { | |
| resultBox.innerHTML = "<h3 style='color: var(--danger)'>Gagal terhubung ke server Backend.</h3>"; | |
| } | |
| } | |