Sentiment / templates /index.html
NzTama's picture
Initial clean deploy: Sentiment Analysis
fa8ff66
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SentiScope β€” Sentiment Analysis Dashboard</title>
<meta name="description" content="Dashboard analisis sentimen media sosial dengan scraping otomatis, word cloud, dan indoBERT.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ── Reset & Base ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07071a;
--surface: #0e0e28;
--surface-2: #14143a;
--border: rgba(130, 100, 255, 0.18);
--border-hover: rgba(130, 100, 255, 0.42);
--purple: #7c3aed;
--purple-light: #a855f7;
--cyan: #06b6d4;
--text: #e2e8f0;
--text-muted: #8892a4;
--text-dim: #4b5563;
--radius: 14px;
--radius-sm: 8px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 70% 50% at 15% 20%, rgba(124,58,237,0.12) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 85% 75%, rgba(6,182,212,0.10) 0%, transparent 60%),
radial-gradient(ellipse 40% 35% at 50% 5%, rgba(168,85,247,0.08) 0%, transparent 55%);
pointer-events: none;
z-index: 0;
}
/* ── Layout ────────────────────────────────────────────────────────── */
.wrapper {
position: relative;
z-index: 1;
max-width: 920px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
}
/* ── Hero ───────────────────────────────────────────────────────────── */
.hero { text-align: center; margin-bottom: 2.5rem; }
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
background: rgba(124,58,237,0.15);
border: 1px solid rgba(124,58,237,0.35);
border-radius: 100px;
padding: 0.28rem 0.9rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--purple-light);
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 1rem;
}
.hero h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 700;
line-height: 1.15;
background: linear-gradient(135deg, #c084fc 0%, #818cf8 40%, #38bdf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.7rem;
}
.hero p {
color: var(--text-muted);
font-size: 0.95rem;
max-width: 520px;
margin: 0 auto;
line-height: 1.6;
}
/* ── Tab navigation ────────────────────────────────────────────────── */
.tab-nav {
display: flex;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.4rem;
margin-bottom: 2rem;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.7rem 1.2rem;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-family: 'Inter', sans-serif;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); }
.tab-btn.active {
background: linear-gradient(135deg, rgba(124,58,237,0.35), rgba(6,182,212,0.2));
color: #fff;
font-weight: 600;
box-shadow: 0 0 0 1px rgba(124,58,237,0.5) inset;
}
/* ── Tab panels ─────────────────────────────────────────────────────── */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Glass card ─────────────────────────────────────────────────────── */
.card {
background: linear-gradient(135deg, rgba(14,14,40,0.9) 0%, rgba(20,20,58,0.75) 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.6rem;
margin-bottom: 1.25rem;
backdrop-filter: blur(12px);
transition: border-color var(--transition), box-shadow var(--transition);
}
.card:hover { border-color: var(--border-hover); }
/* ── Platform header ────────────────────────────────────────────────── */
.platform-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.1rem;
}
.platform-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-family: 'Space Grotesk', sans-serif;
font-size: 1rem;
font-weight: 600;
color: #c4b5fd;
}
.platform-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.pi-instagram { background: linear-gradient(135deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888); }
.pi-tiktok { background: #161823; border: 1px solid #333; }
.pi-facebook { background: #1877f2; }
.pi-news { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.pi-dataset { background: linear-gradient(135deg, #059669, #0891b2); }
/* ── Toggle switch ──────────────────────────────────────────────────── */
.toggle-wrap { display: flex; align-items: center; gap: 0.6rem; }
.toggle-label { font-size: 0.78rem; color: var(--text-dim); font-weight: 500; }
.toggle { position: relative; width: 42px; height: 24px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.1);
border-radius: 100px;
cursor: pointer;
transition: var(--transition);
}
.slider::before {
content: '';
position: absolute;
width: 18px;
height: 18px;
left: 3px;
top: 3px;
background: white;
border-radius: 50%;
transition: var(--transition);
}
.toggle input:checked + .slider { background: linear-gradient(135deg, var(--purple), var(--cyan)); }
.toggle input:checked + .slider::before { transform: translateX(18px); }
.platform-fields {
overflow: hidden;
transition: max-height 0.35s ease, opacity 0.3s ease;
}
.platform-fields.collapsed {
max-height: 0 !important;
opacity: 0;
pointer-events: none;
}
/* ── Form elements ──────────────────────────────────────────────────── */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.form-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; letter-spacing: 0.01em; }
input[type="text"],
input[type="password"],
input[type="number"],
textarea,
select {
background: rgba(7,7,26,0.7);
border: 1px solid rgba(130,100,255,0.2);
border-radius: var(--radius-sm);
color: var(--text);
padding: 0.65rem 0.9rem;
font-family: 'Inter', sans-serif;
font-size: 0.88rem;
width: 100%;
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
}
input::placeholder, textarea::placeholder { color: var(--text-dim); }
input:focus, textarea:focus, select:focus {
border-color: var(--purple);
box-shadow: 0 0 0 3px rgba(124,58,237,0.2);
}
select option { background: var(--surface-2); }
textarea { resize: vertical; min-height: 88px; line-height: 1.5; }
.field-hint { font-size: 0.72rem; color: var(--text-dim); line-height: 1.4; margin-top: 0.2rem; }
/* ── Cookie tabs ────────────────────────────────────────────────────── */
.cookie-tabs { display: flex; gap: 0.3rem; margin-bottom: 0.5rem; }
.cookie-tab-btn {
padding: 0.25rem 0.7rem;
font-size: 0.72rem;
font-weight: 600;
border: 1px solid rgba(130,100,255,0.25);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: var(--transition);
}
.cookie-tab-btn.active {
background: rgba(124,58,237,0.25);
color: #c4b5fd;
border-color: rgba(124,58,237,0.5);
}
/* ── Tag hint ───────────────────────────────────────────────────────── */
.tag-hint {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--cyan);
background: rgba(6,182,212,0.1);
border: 1px solid rgba(6,182,212,0.25);
border-radius: 6px;
padding: 0.15rem 0.55rem;
margin-top: 0.3rem;
}
/* ── Portal chips ───────────────────────────────────────────────────── */
.portal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
}
.portal-chip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
border: 1px solid rgba(130,100,255,0.2);
border-radius: var(--radius-sm);
cursor: pointer;
background: rgba(7,7,26,0.5);
transition: var(--transition);
user-select: none;
}
.portal-chip:hover { border-color: rgba(130,100,255,0.45); background: rgba(124,58,237,0.1); }
.portal-chip input[type="checkbox"] { display: none; }
.portal-chip.checked { border-color: var(--purple); background: rgba(124,58,237,0.2); }
.chip-label { font-size: 0.82rem; font-weight: 500; color: var(--text-muted); }
.portal-chip.checked .chip-label { color: var(--text); }
.chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
transition: var(--transition);
}
.portal-chip.checked .chip-dot { background: var(--purple-light); }
/* ── Submit button ──────────────────────────────────────────────────── */
.btn-submit {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 50%, #0891b2 100%);
border: none;
border-radius: var(--radius);
color: #fff;
font-family: 'Space Grotesk', sans-serif;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity var(--transition), transform var(--transition), box-shadow var(--transition);
letter-spacing: 0.02em;
margin-top: 0.5rem;
position: relative;
overflow: hidden;
}
.btn-submit::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.12), transparent);
opacity: 0;
transition: opacity var(--transition);
}
.btn-submit:hover::before { opacity: 1; }
.btn-submit:hover { transform: translateY(-2px); box-shadow: 0 8px 32px rgba(124,58,237,0.45); }
.btn-submit:active { transform: translateY(0); }
.btn-submit:disabled { opacity: 0.65; pointer-events: none; cursor: not-allowed; transform: none; }
/* ── Spinner ────────────────────────────────────────────────────────── */
.spinner {
display: none;
width: 18px;
height: 18px;
border: 2.5px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Alert ──────────────────────────────────────────────────────────── */
.alert {
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
font-size: 0.88rem;
border: 1px solid;
display: flex;
gap: 0.6rem;
align-items: flex-start;
}
.alert-error {
background: rgba(239,68,68,0.08);
border-color: rgba(239,68,68,0.3);
color: #fca5a5;
}
/* ── Results section ────────────────────────────────────────────────── */
.results-section { margin-top: 2.5rem; }
.results-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.5rem;
}
.results-header h2 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.3rem;
font-weight: 700;
background: linear-gradient(135deg, var(--cyan), var(--purple-light));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stats-strip {
font-size: 0.8rem;
color: var(--text-dim);
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.4rem 0.9rem;
margin-left: auto;
}
/* ── Sentiment cards ───────────────────────────────────────────────── */
.sentiment-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.s-card {
border-radius: var(--radius);
padding: 1.4rem 1rem;
text-align: center;
border: 1px solid;
position: relative;
overflow: hidden;
}
.s-card::before { content: ''; position: absolute; inset: 0; opacity: 0.06; border-radius: inherit; }
.s-card.positif { background: rgba(34,197,94,0.08); border-color: rgba(34,197,94,0.3); }
.s-card.positif::before { background: #22c55e; }
.s-card.negatif { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.3); }
.s-card.negatif::before { background: #ef4444; }
.s-card.netral { background: rgba(148,163,184,0.06); border-color: rgba(148,163,184,0.2); }
.s-card.netral::before { background: #94a3b8; }
.s-count { font-family: 'Space Grotesk', sans-serif; font-size: 2.8rem; font-weight: 700; line-height: 1; margin-bottom: 0.3rem; }
.s-card.positif .s-count { color: #4ade80; }
.s-card.negatif .s-count { color: #f87171; }
.s-card.netral .s-count { color: #94a3b8; }
.s-label { font-size: 0.82rem; color: var(--text-muted); font-weight: 500; }
.s-bar-wrap { margin-top: 0.8rem; height: 4px; background: rgba(255,255,255,0.08); border-radius: 100px; overflow: hidden; }
.s-bar { height: 100%; border-radius: 100px; transition: width 1.2s cubic-bezier(0.4,0,0.2,1); }
.s-card.positif .s-bar { background: linear-gradient(90deg, #16a34a, #4ade80); }
.s-card.negatif .s-bar { background: linear-gradient(90deg, #b91c1c, #f87171); }
.s-card.netral .s-bar { background: linear-gradient(90deg, #475569, #94a3b8); }
/* ── Word cloud ─────────────────────────────────────────────────────── */
.wordcloud-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
text-align: center;
}
.wordcloud-card h3 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1rem;
color: var(--purple-light);
margin-bottom: 1rem;
}
.wordcloud-img { max-width: 100%; border-radius: 10px; border: 1px solid var(--border); }
/* ── Divider ────────────────────────────────────────────────────────── */
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-dim);
font-size: 0.75rem;
margin: 0.75rem 0;
}
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ── Section label ──────────────────────────────────────────────────── */
.section-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 0.6rem;
}
/* ── File upload ────────────────────────────────────────────────────── */
.upload-zone {
border: 2px dashed rgba(130,100,255,0.28);
border-radius: var(--radius);
padding: 2.5rem 1.5rem;
text-align: center;
transition: var(--transition);
cursor: pointer;
background: rgba(124,58,237,0.04);
position: relative;
}
.upload-zone:hover, .upload-zone.drag-over { border-color: var(--purple); background: rgba(124,58,237,0.1); }
.upload-zone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.upload-icon { font-size: 2rem; margin-bottom: 0.5rem; }
.upload-text { font-size: 0.9rem; color: var(--text-muted); }
.upload-sub { font-size: 0.78rem; color: var(--text-dim); margin-top: 0.3rem; }
.upload-filename { display: none; margin-top: 0.6rem; font-size: 0.82rem; color: var(--cyan); font-weight: 500; }
/* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.form-row { grid-template-columns: 1fr; }
.form-row.cols-3 { grid-template-columns: 1fr 1fr; }
.sentiment-grid { grid-template-columns: 1fr; }
.tab-btn span.tab-text { display: none; }
.hero h1 { font-size: 1.8rem; }
}
/* ── Animations ─────────────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in { animation: fadeUp 0.5s ease both; }
.delay-1 { animation-delay: 0.05s; }
.delay-2 { animation-delay: 0.10s; }
.delay-3 { animation-delay: 0.15s; }
.delay-4 { animation-delay: 0.20s; }
.delay-5 { animation-delay: 0.25s; }
</style>
</head>
<body>
<div class="wrapper">
<!-- Hero -->
<header class="hero animate-in">
<div class="hero-badge">πŸ”¬ AI-Powered</div>
<h1>SentiScope</h1>
<p>Analisis sentimen media sosial otomatis dengan IndoBERT β€” Instagram, TikTok, Facebook & Berita Online.</p>
</header>
<!-- Error alert -->
{% if error %}
<div class="alert alert-error animate-in" role="alert">
<span>⚠️</span>
<span>{{ error }}</span>
</div>
{% endif %}
<!-- Tab navigation -->
<nav class="tab-nav animate-in delay-1" role="tablist">
<button class="tab-btn {% if active_tab != 'dataset' %}active{% endif %}"
id="tab-scraping" role="tab" onclick="switchTab('scraping')">
<span class="tab-icon">πŸ•·οΈ</span>
<span class="tab-text">Scraping Otomatis</span>
</button>
<button class="tab-btn {% if active_tab == 'dataset' %}active{% endif %}"
id="tab-dataset" role="tab" onclick="switchTab('dataset')">
<span class="tab-icon">πŸ“‚</span>
<span class="tab-text">Upload Dataset</span>
</button>
</nav>
<!-- ═══════════════════════ TAB 1: Scraping ═══════════════════════════ -->
<div class="tab-panel {% if active_tab != 'dataset' %}active{% endif %}" id="panel-scraping">
<form id="scraping-form" action="/process" method="post">
<!-- Hidden enable flags β€” managed by JS toggles -->
<input type="hidden" id="enable_instagram" name="enable_instagram" value="">
<input type="hidden" id="enable_tiktok" name="enable_tiktok" value="">
<input type="hidden" id="enable_facebook" name="enable_facebook" value="">
<input type="hidden" id="enable_news" name="enable_news" value="">
<!-- ── Instagram ──────────────────────────────────────────────── -->
<div class="card animate-in delay-2">
<div class="platform-header">
<div class="platform-title">
<div class="platform-icon pi-instagram">πŸ“Έ</div>
Instagram
</div>
<div class="toggle-wrap">
<span class="toggle-label" id="ig-toggle-label">Nonaktif</span>
<label class="toggle">
<input type="checkbox" id="ig-toggle" onchange="togglePlatform('ig')">
<span class="slider"></span>
</label>
</div>
</div>
<div class="platform-fields collapsed" id="ig-fields" style="max-height:600px;">
<div class="form-row" style="margin-bottom:0.9rem;">
<div class="form-group">
<label for="ig_username">Username Instagram</label>
<input id="ig_username" type="text" name="ig_username" placeholder="akun_instagram" autocomplete="username">
</div>
<div class="form-group">
<label for="ig_password">Password Instagram</label>
<input id="ig_password" type="password" name="ig_password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password">
</div>
</div>
<div class="form-row">
<div class="form-group full">
<label for="target_accounts">Target Akun / #Hashtag (satu per baris)</label>
<textarea id="target_accounts" name="target_accounts"
placeholder="cirebonkab&#10;@rctvcirebon&#10;#jalanrusak"></textarea>
<span class="tag-hint">↡ Satu target per baris, @ dan # opsional</span>
</div>
<div class="form-group">
<label for="mode">Mode Waktu</label>
<select id="mode" name="mode">
<option value="all">Semua Postingan</option>
<option value="date">7 Bulan Terakhir</option>
</select>
</div>
</div>
</div>
</div>
<!-- ── TikTok ──────────────────────────────────────────────────── -->
<div class="card animate-in delay-3">
<div class="platform-header">
<div class="platform-title">
<div class="platform-icon pi-tiktok">🎡</div>
TikTok
</div>
<div class="toggle-wrap">
<span class="toggle-label" id="tt-toggle-label">Nonaktif</span>
<label class="toggle">
<input type="checkbox" id="tt-toggle" onchange="togglePlatform('tt')">
<span class="slider"></span>
</label>
</div>
</div>
<div class="platform-fields collapsed" id="tt-fields" style="max-height:500px;">
<div class="form-group" style="margin-bottom:0.9rem;">
<label>Format Cookie TikTok</label>
<div class="cookie-tabs">
<button type="button" class="cookie-tab-btn active" onclick="setCookieHint('raw',this)">String Mentah</button>
<button type="button" class="cookie-tab-btn" onclick="setCookieHint('json_arr',this)">JSON Array</button>
<button type="button" class="cookie-tab-btn" onclick="setCookieHint('json_obj',this)">JSON Object</button>
</div>
<textarea id="tiktok_cookie" name="tiktok_cookie"
placeholder="sessionid=xxx; tt_webid=yyy; ..."
style="min-height:70px;font-family:monospace;font-size:0.8rem;"></textarea>
<p class="field-hint" id="cookie-hint">
Format: <code>sessionid=ABC; tt_webid=123</code> β€” ambil dari DevTools β†’ Application β†’ Cookies β†’ tiktok.com
</p>
</div>
<div class="form-group">
<label for="tiktok_targets">Target Username TikTok (satu per baris)</label>
<textarea id="tiktok_targets" name="tiktok_targets"
placeholder="@rctvcirebon&#10;@cirebonnews&#10;kuningan_update"></textarea>
<span class="tag-hint">↡ Satu username per baris, @ opsional</span>
</div>
</div>
</div>
<!-- ── Facebook ────────────────────────────────────────────────── -->
<div class="card animate-in delay-3">
<div class="platform-header">
<div class="platform-title">
<div class="platform-icon pi-facebook">πŸ“˜</div>
Facebook
</div>
<div class="toggle-wrap">
<span class="toggle-label" id="fb-toggle-label">Nonaktif</span>
<label class="toggle">
<input type="checkbox" id="fb-toggle" onchange="togglePlatform('fb')">
<span class="slider"></span>
</label>
</div>
</div>
<div class="platform-fields collapsed" id="fb-fields" style="max-height:500px;">
<div class="form-row" style="margin-bottom:0.9rem;">
<div class="form-group">
<label for="fb_username">Email / No. HP Facebook</label>
<input id="fb_username" type="text" name="fb_username" placeholder="email@contoh.com" autocomplete="username">
</div>
<div class="form-group">
<label for="fb_password">Password Facebook</label>
<input id="fb_password" type="password" name="fb_password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password">
</div>
</div>
<div class="form-group">
<label for="facebook_groups">URL Grup Facebook (satu per baris, wajib diisi)</label>
<textarea id="facebook_groups" name="facebook_groups"
placeholder="https://web.facebook.com/groups/123456&#10;https://web.facebook.com/groups/teraswarga"></textarea>
<p class="field-hint">⚠️ Harus diisi β€” tidak ada grup default. Jika kosong, Facebook tidak akan di-scrape.</p>
</div>
</div>
</div>
<!-- ── Berita Online ───────────────────────────────────────────── -->
<div class="card animate-in delay-4">
<div class="platform-header">
<div class="platform-title">
<div class="platform-icon pi-news">πŸ“°</div>
Berita Online
</div>
<div class="toggle-wrap">
<span class="toggle-label" id="news-toggle-label">Nonaktif</span>
<label class="toggle">
<input type="checkbox" id="news-toggle" onchange="togglePlatform('news')">
<span class="slider"></span>
</label>
</div>
</div>
<div class="platform-fields collapsed" id="news-fields" style="max-height:500px;">
<div class="section-label">Pilih Portal (bisa lebih dari satu)</div>
<div class="portal-grid" id="portal-grid">
<label class="portal-chip" onclick="toggleChip(this)">
<input type="checkbox" name="_portal_detik" value="detik">
<span class="chip-dot"></span><span class="chip-label">Detik.com</span>
</label>
<label class="portal-chip" onclick="toggleChip(this)">
<input type="checkbox" name="_portal_antara" value="antara">
<span class="chip-dot"></span><span class="chip-label">Antara News</span>
</label>
<label class="portal-chip" onclick="toggleChip(this)">
<input type="checkbox" name="_portal_radar" value="radar">
<span class="chip-dot"></span><span class="chip-label">Radar (Disway)</span>
</label>
<label class="portal-chip" onclick="toggleChip(this)">
<input type="checkbox" name="_portal_radarcirebon" value="radarcirebon">
<span class="chip-dot"></span><span class="chip-label">Radar Cirebon ID</span>
</label>
<label class="portal-chip" onclick="toggleChip(this)">
<input type="checkbox" name="_portal_cnn" value="cnn">
<span class="chip-dot"></span><span class="chip-label">CNN Indonesia</span>
</label>
</div>
<!-- Hidden field filled by JS -->
<input type="hidden" id="news_portals" name="news_portals" value="">
<div class="form-row" style="margin-top:1rem;">
<div class="form-group">
<label for="news_keyword">Keyword Pencarian</label>
<input id="news_keyword" type="text" name="news_keyword" value="kabupaten cirebon" placeholder="kabupaten cirebon">
</div>
<div class="form-group">
<label for="news_pages">Jumlah Halaman per Portal</label>
<input id="news_pages" type="number" name="news_pages" value="1" min="1" max="20">
</div>
</div>
</div>
</div>
<button class="btn-submit animate-in delay-5" type="submit" id="scraping-submit">
<span class="spinner" id="scraping-spinner"></span>
<span id="scraping-btn-text">⚑ Mulai Scraping &amp; Analisis</span>
</button>
</form>
</div>
<!-- ═══════════════════════ TAB 2: Dataset ════════════════════════════ -->
<div class="tab-panel {% if active_tab == 'dataset' %}active{% endif %}" id="panel-dataset">
<form id="dataset-form" action="/wordcloud-dataset" method="post" enctype="multipart/form-data">
<div class="card animate-in">
<div class="platform-header">
<div class="platform-title">
<div class="platform-icon pi-dataset">πŸ“‚</div>
Upload Dataset
</div>
</div>
<div class="form-group" style="margin-bottom:1.25rem;">
<label>File Dataset (CSV, JSON, atau TXT)</label>
<div class="upload-zone" id="upload-zone">
<input type="file" name="dataset_file" id="dataset_file"
accept=".csv,.json,.txt,.tsv"
onchange="showFilename(this)">
<div class="upload-icon">πŸ“</div>
<div class="upload-text">Klik atau seret file ke sini</div>
<div class="upload-sub">Mendukung .csv, .json, .txt β€” maks 50 MB</div>
<div class="upload-filename" id="upload-filename">βœ“ <span></span></div>
</div>
</div>
<div class="form-group" style="margin-bottom:1.25rem;">
<label for="text_column">Nama Kolom Teks (untuk CSV/JSON)</label>
<input id="text_column" type="text" name="text_column" value="text" placeholder="text / content / komentar">
<p class="field-hint">Kolom yang berisi teks yang akan dianalisis. Kosongkan untuk pakai kolom pertama.</p>
</div>
<div class="divider">atau paste teks langsung</div>
<div class="form-group">
<label for="dataset_text">Teks Dataset (satu dokumen/kalimat per baris)</label>
<textarea id="dataset_text" name="dataset_text" style="min-height:140px;"
placeholder="Masukkan teks di sini, satu kalimat per baris...&#10;Cirebon semakin maju dengan infrastruktur yang baik&#10;Jalan di daerah X masih rusak parah"></textarea>
</div>
</div>
<button class="btn-submit" type="submit" id="dataset-submit">
<span class="spinner" id="dataset-spinner"></span>
<span id="dataset-btn-text">☁️ Buat Word Cloud &amp; Analisis Sentimen</span>
</button>
</form>
</div>
<!-- ═══════════════════════ Hasil Analisis ════════════════════════════ -->
{% if result %}
<section class="results-section animate-in">
<div class="results-header">
<h2>πŸ“Š Hasil Analisis Sentimen</h2>
<span class="stats-strip">{{ total_scraped }} teks dikumpulkan Β· {{ result.total }} dianalisis</span>
</div>
{% if csv_filename %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ csv_filename }}" download class="btn-submit" style="display:inline-flex; width:auto; padding:0.7rem 1.25rem; background:linear-gradient(135deg, #059669, #10b981); text-decoration:none; font-size:0.9rem;">
πŸ“₯ Download Data Scraping (CSV)
</a>
</div>
{% endif %}
<div class="sentiment-grid">
{% set total = result.total if result.total > 0 else 1 %}
<div class="s-card positif">
<div class="s-count" id="count-pos">0</div>
<div class="s-label">😊 Positif</div>
<div class="s-bar-wrap"><div class="s-bar" id="bar-pos" style="width:0%"></div></div>
</div>
<div class="s-card negatif">
<div class="s-count" id="count-neg">0</div>
<div class="s-label">😠 Negatif</div>
<div class="s-bar-wrap"><div class="s-bar" id="bar-neg" style="width:0%"></div></div>
</div>
<div class="s-card netral">
<div class="s-count" id="count-neu">0</div>
<div class="s-label">😐 Netral</div>
<div class="s-bar-wrap"><div class="s-bar" id="bar-neu" style="width:0%"></div></div>
</div>
</div>
{% if image %}
<div class="wordcloud-card">
<h3>☁️ Word Cloud</h3>
<img class="wordcloud-img" src="data:image/png;base64,{{ image }}" alt="Word Cloud">
</div>
{% endif %}
</section>
<script>
(function () {
var pos = {{ result.positif }};
var neg = {{ result.negatif }};
var neu = {{ result.netral }};
var total = {{ result.total if result.total > 0 else 1 }};
function animCount(el, target) {
var start = 0;
var step = Math.max(1, Math.ceil(target / 40));
var timer = setInterval(function () {
start = Math.min(start + step, target);
el.textContent = start;
if (start >= target) clearInterval(timer);
}, 25);
}
setTimeout(function () {
animCount(document.getElementById('count-pos'), pos);
animCount(document.getElementById('count-neg'), neg);
animCount(document.getElementById('count-neu'), neu);
document.getElementById('bar-pos').style.width = (pos / total * 100).toFixed(1) + '%';
document.getElementById('bar-neg').style.width = (neg / total * 100).toFixed(1) + '%';
document.getElementById('bar-neu').style.width = (neu / total * 100).toFixed(1) + '%';
}, 300);
})();
</script>
{% endif %}
</div><!-- /wrapper -->
<script>
// ── Tab switching ─────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.getElementById('panel-' + name).classList.add('active');
}
// ── Platform toggle ───────────────────────────────────────────────────────
function togglePlatform(id) {
var fields = document.getElementById(id + '-fields');
var toggle = document.getElementById(id + '-toggle');
var label = document.getElementById(id + '-toggle-label');
var flagMap = { ig: 'enable_instagram', tt: 'enable_tiktok', fb: 'enable_facebook', news: 'enable_news' };
if (toggle.checked) {
fields.classList.remove('collapsed');
if (label) label.textContent = 'Aktif';
document.getElementById(flagMap[id]).value = '1';
} else {
fields.classList.add('collapsed');
if (label) label.textContent = 'Nonaktif';
document.getElementById(flagMap[id]).value = '';
}
}
// ── Portal chip multi-select ──────────────────────────────────────────────
function toggleChip(label) {
var cb = label.querySelector('input[type="checkbox"]');
cb.checked = !cb.checked;
label.classList.toggle('checked', cb.checked);
updatePortalField();
}
function updatePortalField() {
var vals = [];
document.querySelectorAll('#portal-grid .portal-chip.checked input').forEach(function (cb) {
vals.push(cb.value);
});
document.getElementById('news_portals').value = vals.join(',');
}
// ── Cookie format hints ───────────────────────────────────────────────────
var cookieHints = {
raw: 'Format: <code>sessionid=ABC; tt_webid=123</code> β€” ambil dari DevTools β†’ Application β†’ Cookies β†’ tiktok.com',
json_arr: 'Format JSON Array: <code>[{"name":"sessionid","value":"ABC","domain":".tiktok.com"}]</code>',
json_obj: 'Format JSON Object: <code>{"sessionid": "ABC", "tt_webid": "123"}</code>',
};
var cookiePlaceholders = {
raw: 'sessionid=xxx; tt_webid=yyy; ...',
json_arr: '[{"name":"sessionid","value":"xxx","domain":".tiktok.com"},...]',
json_obj: '{"sessionid": "xxx", "tt_webid": "yyy"}',
};
function setCookieHint(fmt, btn) {
document.querySelectorAll('.cookie-tab-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
document.getElementById('cookie-hint').innerHTML = cookieHints[fmt];
document.getElementById('tiktok_cookie').placeholder = cookiePlaceholders[fmt];
}
// ── File upload label ─────────────────────────────────────────────────────
function showFilename(input) {
var wrap = document.getElementById('upload-filename');
if (input.files && input.files[0]) {
wrap.style.display = 'block';
wrap.querySelector('span').textContent = input.files[0].name;
} else {
wrap.style.display = 'none';
}
}
// Drag-over styling
var zone = document.getElementById('upload-zone');
if (zone) {
zone.addEventListener('dragover', function (e) { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', function () { zone.classList.remove('drag-over'); });
zone.addEventListener('drop', function () { zone.classList.remove('drag-over'); });
}
// ── Form submit spinners ──────────────────────────────────────────────────
function bindSubmit(formId, spinnerId, btnTextId, btnId, loadingText) {
var form = document.getElementById(formId);
if (!form) return;
form.addEventListener('submit', function () {
document.getElementById(btnId).disabled = true;
document.getElementById(spinnerId).style.display = 'inline-block';
document.getElementById(btnTextId).innerHTML = loadingText + '<span class="dots"><span></span><span></span><span></span></span>';
});
}
bindSubmit('scraping-form', 'scraping-spinner', 'scraping-btn-text', 'scraping-submit', 'Memproses (mungkin beberapa menit)');
bindSubmit('dataset-form', 'dataset-spinner', 'dataset-btn-text', 'dataset-submit', 'Memproses dataset');
// Build news_portals on submit (capture phase)
var sf = document.getElementById('scraping-form');
if (sf) sf.addEventListener('submit', updatePortalField, true);
</script>
</body>
</html>