tim / public /index.html
Aqso's picture
Upload index.html
822e778 verified
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anichin Scraper β€” Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet" />
<style>
/* ── RESET & BASE ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #111118;
--surface2: #16161f;
--border: #1e1e2e;
--accent: #e64040;
--accent2: #ff7043;
--green: #4ade80;
--yellow: #fbbf24;
--text: #e2e2ef;
--muted: #6b6b8a;
--font-display: 'Bebas Neue', sans-serif;
--font-mono: 'DM Mono', monospace;
--font-body: 'DM Sans', sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
min-height: 100vh;
overflow-x: hidden;
}
/* ── NOISE OVERLAY ── */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}
/* ── SIDEBAR ── */
.sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 0;
z-index: 100;
}
.sidebar-logo {
padding: 28px 24px 20px;
border-bottom: 1px solid var(--border);
}
.sidebar-logo h1 {
font-family: var(--font-display);
font-size: 28px;
letter-spacing: 2px;
color: var(--accent);
line-height: 1;
}
.sidebar-logo p {
font-size: 10px;
color: var(--muted);
font-family: var(--font-mono);
margin-top: 4px;
letter-spacing: 1px;
text-transform: uppercase;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 4px 10px;
border-radius: 20px;
font-family: var(--font-mono);
font-size: 10px;
background: #1a1a2a;
border: 1px solid var(--border);
}
.status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--green);
}
.status-dot.running {
background: var(--yellow);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.nav {
flex: 1;
padding: 16px 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--muted);
transition: all 0.15s;
margin-bottom: 2px;
}
.nav-item:hover { background: var(--surface2); color: var(--text); }
.nav-item.active { background: rgba(230, 64, 64, 0.1); color: var(--accent); border-left: 2px solid var(--accent); }
.nav-icon { font-size: 16px; width: 20px; text-align: center; }
/* ── MAIN CONTENT ── */
.main {
margin-left: 240px;
min-height: 100vh;
padding: 32px;
}
/* ── HEADER ── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 32px;
}
.page-title {
font-family: var(--font-display);
font-size: 48px;
letter-spacing: 3px;
line-height: 1;
background: linear-gradient(135deg, var(--text) 0%, var(--muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-sub {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
margin-top: 6px;
letter-spacing: 1px;
}
/* ── STAT CARDS ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
}
.stat-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-value {
font-family: var(--font-display);
font-size: 38px;
letter-spacing: 2px;
margin-top: 8px;
color: var(--text);
}
/* ── PANELS ── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 20px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 2px;
}
.panel-body { padding: 20px; }
/* ── SCRAPE CONTROLS ── */
.controls-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.control-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
}
.control-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.control-card p {
font-size: 12px;
color: var(--muted);
margin-bottom: 16px;
line-height: 1.5;
}
.input-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
input[type=text], input[type=number], input[type=url] {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
transition: border 0.2s;
}
input:focus { border-color: var(--accent); }
/* ── BUTTONS ── */
.btn {
padding: 9px 18px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: #c0392b; }
.btn-secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.btn-secondary:hover { border-color: var(--text); color: var(--text); }
.btn-green {
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.3);
color: var(--green);
}
.btn-green:hover { background: rgba(74, 222, 128, 0.2); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── LOG TERMINAL ── */
.terminal {
background: #060609;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
height: 220px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.8;
}
.terminal::-webkit-scrollbar { width: 4px; }
.terminal::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.log-entry { color: var(--muted); }
.log-entry.info { color: var(--text); }
.log-entry.success { color: var(--green); }
.log-entry.error { color: var(--accent); }
.log-entry.warn { color: var(--yellow); }
.log-time {
color: var(--border);
margin-right: 8px;
}
/* ── ANIME TABLE ── */
.anime-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.anime-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
cursor: pointer;
}
.anime-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.anime-thumb {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
background: var(--border);
display: block;
}
.anime-thumb-placeholder {
width: 100%;
aspect-ratio: 2/3;
background: linear-gradient(135deg, #1a1a2e, #16213e);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
.anime-info {
padding: 10px;
}
.anime-title {
font-size: 11px;
font-weight: 600;
line-height: 1.3;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.anime-meta {
display: flex;
gap: 4px;
}
.badge {
font-family: var(--font-mono);
font-size: 9px;
padding: 2px 5px;
border-radius: 3px;
background: var(--border);
color: var(--muted);
}
.badge.ongoing { background: rgba(74, 222, 128, 0.1); color: var(--green); }
.badge.completed { background: rgba(96, 165, 250, 0.1); color: #60a5fa; }
/* ── PROGRESS BAR ── */
.progress-wrap {
background: var(--border);
border-radius: 99px;
height: 4px;
margin-top: 12px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: 99px;
transition: width 0.5s ease;
}
/* ── TABS ── */
.tabs {
display: flex;
gap: 2px;
margin-bottom: 20px;
background: var(--surface2);
border-radius: 8px;
padding: 4px;
width: fit-content;
}
.tab {
padding: 7px 16px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
color: var(--muted);
transition: all 0.15s;
}
.tab.active {
background: var(--surface);
color: var(--text);
}
/* ── SEARCH ── */
.search-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
/* ── VIEWS ── */
.view { display: none; }
.view.active { display: block; }
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
transform: translateY(80px);
opacity: 0;
transition: all 0.3s;
z-index: 9999;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: var(--green); color: var(--green); }
#toast.error { border-color: var(--accent); color: var(--accent); }
/* ── EMPTY STATE ── */
.empty {
text-align: center;
padding: 60px 20px;
color: var(--muted);
}
.empty-icon { font-size: 48px; margin-bottom: 16px; }
.empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text); }
.empty p { font-size: 12px; line-height: 1.5; }
/* ── PAGINATION ── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 24px;
}
/* ── RESPONSIVE ── */
@media (max-width: 900px) {
.sidebar { width: 60px; }
.sidebar-logo h1, .sidebar-logo p, .status-pill, .nav-item span { display: none; }
.main { margin-left: 60px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<!-- ── SIDEBAR ── -->
<aside class="sidebar">
<div class="sidebar-logo">
<h1>ANICHIN</h1>
<p>Scraper Dashboard</p>
<div class="status-pill">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Ready</span>
</div>
</div>
<nav class="nav">
<div class="nav-item active" onclick="switchView('scraper')">
<span class="nav-icon">⚑</span>
<span>Scraper</span>
</div>
<div class="nav-item" onclick="switchView('library')">
<span class="nav-icon">πŸ—ƒοΈ</span>
<span>Library</span>
</div>
<div class="nav-item" onclick="switchView('logs')">
<span class="nav-icon">πŸ“‹</span>
<span>Logs</span>
</div>
</nav>
</aside>
<!-- ── MAIN ── -->
<main class="main">
<!-- ── VIEW: SCRAPER ── -->
<div class="view active" id="view-scraper">
<div class="page-header">
<div>
<div class="page-title">SCRAPER</div>
<div class="page-sub">// anichin.cafe β†’ firebase firestore</div>
</div>
<button class="btn btn-secondary" onclick="refreshStats()">⟳ Refresh Stats</button>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Anime</div>
<div class="stat-value" id="statTotal">β€”</div>
</div>
<div class="stat-card">
<div class="stat-label">Ongoing</div>
<div class="stat-value" id="statOngoing">β€”</div>
</div>
<div class="stat-card">
<div class="stat-label">Last Scrape</div>
<div class="stat-value" style="font-size:16px; margin-top:16px;" id="statLastRun">Never</div>
</div>
<div class="stat-card">
<div class="stat-label">Status</div>
<div class="stat-value" style="font-size:22px; margin-top:12px;" id="statStatus">IDLE</div>
</div>
</div>
<!-- Controls -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">// Scrape Controls</span>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--muted)" id="progressLabel"></span>
</div>
<div class="panel-body">
<div class="progress-wrap" id="progressWrap" style="display:none">
<div class="progress-bar" id="progressBar" style="width:0%"></div>
</div>
<div class="controls-grid" style="margin-top:16px">
<!-- Full Scrape -->
<div class="control-card">
<h3>πŸš€ Full Scrape</h3>
<p>Scrape semua anime dari halaman list + detail lengkap. Cocok buat pertama kali.</p>
<div class="input-row">
<input type="number" id="pagesInput" value="10" min="1" max="100" placeholder="Jumlah halaman" />
</div>
<button class="btn btn-primary" id="btnFullScrape" onclick="startFullScrape()">
β–Ά Mulai Full Scrape
</button>
</div>
<!-- Incremental Update -->
<div class="control-card">
<h3>πŸ”„ Incremental Update</h3>
<p>Cek episode baru untuk anime yang masih ongoing. Ringan dan cepet.</p>
<p style="margin-bottom:8px;color:var(--yellow)">⏰ Auto-run setiap hari 06:00 WIB</p>
<button class="btn btn-green" id="btnUpdate" onclick="startUpdate()">
↻ Update Sekarang
</button>
</div>
<!-- Single Anime -->
<div class="control-card">
<h3>🎯 Scrape Anime Tertentu</h3>
<p>Input URL anime dari anichin.cafe untuk scrape satu anime aja.</p>
<div class="input-row">
<input type="url" id="singleUrl" placeholder="https://anichin.cafe/anime/..." />
</div>
<button class="btn btn-secondary" onclick="scrapeSingle()">Scrape</button>
</div>
<!-- Search Scrape -->
<div class="control-card">
<h3>πŸ” Search & Scrape</h3>
<p>Cari anime berdasarkan judul dan scrape hasilnya ke database.</p>
<div class="input-row">
<input type="text" id="searchQuery" placeholder="Nama anime..." />
</div>
<button class="btn btn-secondary" onclick="searchScrape()">Cari & Scrape</button>
</div>
</div>
</div>
</div>
<!-- Live Log -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">// Live Log</span>
<button class="btn btn-secondary" onclick="clearLog()" style="padding:4px 10px;font-size:10px">Clear</button>
</div>
<div class="panel-body" style="padding:12px">
<div class="terminal" id="terminal">
<div class="log-entry info">
<span class="log-time">--:--:--</span>
Anichin Scraper siap. Tekan tombol scrape untuk mulai.
</div>
</div>
</div>
</div>
</div>
<!-- ── VIEW: LIBRARY ── -->
<div class="view" id="view-library">
<div class="page-header">
<div>
<div class="page-title">LIBRARY</div>
<div class="page-sub">// anime database dari firebase</div>
</div>
</div>
<div class="search-bar">
<input type="text" id="libSearch" placeholder="πŸ” Cari anime..." style="max-width:300px" oninput="debounceSearch()" />
<select id="libStatus" onchange="loadLibrary()" style="background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px 12px;color:var(--text);font-family:var(--font-mono);font-size:12px;outline:none;">
<option value="">Semua Status</option>
<option value="Ongoing">Ongoing</option>
<option value="Completed">Completed</option>
</select>
</div>
<div class="anime-grid" id="animeGrid">
<div class="empty" style="grid-column:1/-1">
<div class="empty-icon">πŸ“¦</div>
<h3>Library Kosong</h3>
<p>Belum ada anime. Jalankan scraper dulu!</p>
</div>
</div>
<div class="pagination" id="pagination"></div>
</div>
<!-- ── VIEW: LOGS ── -->
<div class="view" id="view-logs">
<div class="page-header">
<div>
<div class="page-title">LOGS</div>
<div class="page-sub">// riwayat scraping activity</div>
</div>
<button class="btn btn-secondary" onclick="loadLogs()">⟳ Refresh</button>
</div>
<div class="panel">
<div class="panel-body" style="padding:12px">
<div class="terminal" id="fullLog" style="height:500px"></div>
</div>
</div>
</div>
</main>
<!-- Toast -->
<div id="toast"></div>
<script>
// ── STATE ──────────────────────────────────────────────────────────────────
let currentPage = 1;
let pollInterval = null;
let searchTimeout = null;
const API = window.location.origin + '/api';
// ── INIT ──────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
refreshStats();
startPolling();
});
// ── VIEWS ─────────────────────────────────────────────────────────────────
function switchView(name) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(`view-${name}`).classList.add('active');
event.currentTarget.classList.add('active');
if (name === 'library') { currentPage = 1; loadLibrary(); }
if (name === 'logs') loadLogs();
}
// ── STATS ─────────────────────────────────────────────────────────────────
async function refreshStats() {
try {
const res = await fetch(`${API}/stats`);
const d = await res.json();
document.getElementById('statTotal').textContent = (d.totalAnimes || 0).toLocaleString();
document.getElementById('statOngoing').textContent = (d.ongoingAnimes || 0).toLocaleString();
if (d.lastScrape) {
document.getElementById('statLastRun').textContent =
new Date(d.lastScrape).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' });
}
} catch { /* server might not be running in demo */ }
}
// ── SCRAPE ACTIONS ────────────────────────────────────────────────────────
async function startFullScrape() {
const pages = parseInt(document.getElementById('pagesInput').value) || 10;
setScrapingUI(true);
addLogEntry(`πŸš€ Full scrape dimulai β€” ${pages} halaman`, 'info');
try {
const res = await fetch(`${API}/scrape/full`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pages }),
});
const d = await res.json();
toast(d.message || 'Scrape dimulai!', 'success');
} catch (err) {
addLogEntry(`❌ Koneksi error: ${err.message}`, 'error');
setScrapingUI(false);
}
}
async function startUpdate() {
setScrapingUI(true);
addLogEntry('πŸ”„ Incremental update dimulai...', 'info');
try {
const res = await fetch(`${API}/scrape/update`, { method: 'POST' });
const d = await res.json();
toast(d.message, 'success');
} catch (err) {
addLogEntry(`❌ ${err.message}`, 'error');
setScrapingUI(false);
}
}
async function scrapeSingle() {
const url = document.getElementById('singleUrl').value.trim();
if (!url) return toast('Input URL dulu!', 'error');
addLogEntry(`🎯 Scrape: ${url}`, 'info');
try {
const res = await fetch(`${API}/scrape/single`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const d = await res.json();
if (d.success) {
addLogEntry(`βœ… ${d.data.title} β€” ${d.data.totalEpisodes} eps`, 'success');
toast('Anime berhasil discrape!', 'success');
} else {
addLogEntry(`❌ ${d.error}`, 'error');
}
} catch (err) {
addLogEntry(`❌ ${err.message}`, 'error');
}
}
async function searchScrape() {
const q = document.getElementById('searchQuery').value.trim();
if (!q) return toast('Masukkin query dulu!', 'error');
addLogEntry(`πŸ” Search: "${q}"`, 'info');
try {
const res = await fetch(`${API}/scrape/search?q=${encodeURIComponent(q)}`);
const d = await res.json();
addLogEntry(`βœ“ Dapet ${d.count} hasil untuk "${q}"`, 'success');
toast(`${d.count} anime ditemukan`, 'success');
} catch (err) {
addLogEntry(`❌ ${err.message}`, 'error');
}
}
// ── LIBRARY ───────────────────────────────────────────────────────────────
async function loadLibrary() {
const status = document.getElementById('libStatus').value;
const search = document.getElementById('libSearch').value;
const grid = document.getElementById('animeGrid');
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--muted)">Loading...</div>';
try {
let url = `${API}/animes?limit=30&page=${currentPage}`;
if (status) url += `&status=${status}`;
const res = await fetch(url);
const d = await res.json();
renderAnimes(d.data || [], d.total || 0);
renderPagination(d.total || 0, 30);
} catch {
grid.innerHTML = `
<div class="empty" style="grid-column:1/-1">
<div class="empty-icon">⚠️</div>
<h3>Server Tidak Tersedia</h3>
<p>Pastikan server Node.js sudah berjalan.<br>Jalankan: <code style="color:var(--accent)">npm start</code></p>
</div>`;
}
}
function renderAnimes(animes, total) {
const grid = document.getElementById('animeGrid');
if (!animes.length) {
grid.innerHTML = `
<div class="empty" style="grid-column:1/-1">
<div class="empty-icon">πŸ“­</div>
<h3>Belum Ada Data</h3>
<p>Jalankan scraper untuk mulai ngumpulin anime!</p>
</div>`;
return;
}
grid.innerHTML = animes.map(a => `
<div class="anime-card" onclick="showDetail('${a.slug || a.id}')">
${a.thumbnail
? `<img class="anime-thumb" src="${escHtml(a.thumbnail)}" alt="${escHtml(a.title)}" loading="lazy" onerror="this.style.display='none'" />`
: `<div class="anime-thumb-placeholder">🎌</div>`}
<div class="anime-info">
<div class="anime-title">${escHtml(a.title || 'β€”')}</div>
<div class="anime-meta">
${a.status ? `<span class="badge ${(a.status||'').toLowerCase()}">${escHtml(a.status)}</span>` : ''}
${a.totalEpisodes ? `<span class="badge">${a.totalEpisodes} eps</span>` : ''}
</div>
</div>
</div>
`).join('');
}
function renderPagination(total, limit) {
const pages = Math.ceil(total / limit);
const pg = document.getElementById('pagination');
if (pages <= 1) { pg.innerHTML = ''; return; }
let html = '';
if (currentPage > 1) html += `<button class="btn btn-secondary" onclick="goPage(${currentPage-1})">← Prev</button>`;
html += `<span style="font-family:var(--font-mono);font-size:11px;color:var(--muted)">${currentPage} / ${pages}</span>`;
if (currentPage < pages) html += `<button class="btn btn-secondary" onclick="goPage(${currentPage+1})">Next β†’</button>`;
pg.innerHTML = html;
}
function goPage(p) { currentPage = p; loadLibrary(); window.scrollTo(0,0); }
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { currentPage = 1; loadLibrary(); }, 400);
}
async function showDetail(slug) {
toast(`Memuat detail ${slug}...`, 'success');
}
// ── LOGS ──────────────────────────────────────────────────────────────────
async function loadLogs() {
try {
const res = await fetch(`${API}/logs`);
const logs = await res.json();
const el = document.getElementById('fullLog');
el.innerHTML = logs.map(l => `
<div class="log-entry ${classifyLog(l.msg)}">
<span class="log-time">${new Date(l.time).toLocaleTimeString('id-ID')}</span>
${escHtml(l.msg)}
</div>`).join('') || '<div class="log-entry">Belum ada log.</div>';
} catch {
document.getElementById('fullLog').innerHTML = '<div class="log-entry error">Server tidak tersedia.</div>';
}
}
// ── POLLING ───────────────────────────────────────────────────────────────
function startPolling() {
pollInterval = setInterval(async () => {
try {
const res = await fetch(`${API}/status`);
const d = await res.json();
const dot = document.getElementById('statusDot');
const statusTxt = document.getElementById('statusText');
const statStatus = document.getElementById('statStatus');
if (d.scrapeRunning) {
dot.className = 'status-dot running';
statusTxt.textContent = 'Running';
statStatus.textContent = d.progress?.stage?.toUpperCase() || 'RUNNING';
document.getElementById('progressWrap').style.display = 'block';
// Fetch fresh logs
const logRes = await fetch(`${API}/logs`);
const logs = await logRes.json();
if (logs.length) updateTerminalFromServer(logs.slice(0, 20));
} else {
dot.className = 'status-dot';
statusTxt.textContent = 'Ready';
statStatus.textContent = 'IDLE';
setScrapingUI(false);
document.getElementById('progressWrap').style.display = 'none';
}
} catch { /* server offline */ }
}, 2500);
}
function updateTerminalFromServer(logs) {
const term = document.getElementById('terminal');
term.innerHTML = logs.map(l => `
<div class="log-entry ${classifyLog(l.msg)}">
<span class="log-time">${new Date(l.time).toLocaleTimeString('id-ID')}</span>
${escHtml(l.msg)}
</div>`).join('');
}
// ── UI HELPERS ────────────────────────────────────────────────────────────
function setScrapingUI(isRunning) {
const btns = ['btnFullScrape', 'btnUpdate'];
btns.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = isRunning;
});
}
function addLogEntry(msg, type = 'info') {
const term = document.getElementById('terminal');
const time = new Date().toLocaleTimeString('id-ID');
const div = document.createElement('div');
div.className = `log-entry ${type}`;
div.innerHTML = `<span class="log-time">${time}</span>${escHtml(msg)}`;
term.insertBefore(div, term.firstChild);
if (term.children.length > 50) term.removeChild(term.lastChild);
}
function classifyLog(msg) {
if (!msg) return '';
if (msg.includes('βœ…') || msg.includes('βœ“')) return 'success';
if (msg.includes('❌')) return 'error';
if (msg.includes('⚠️')) return 'warn';
return 'info';
}
function clearLog() {
document.getElementById('terminal').innerHTML = '';
}
function toast(msg, type = '') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
setTimeout(() => t.className = '', 3000);
}
function escHtml(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
</script>
</body>
</html>