rhmnsae commited on
Commit
04b72bb
·
1 Parent(s): 0069a1e
Files changed (36) hide show
  1. README.md +3 -3
  2. analytics.html +146 -0
  3. app.py +142 -0
  4. cleaning.html +89 -0
  5. convert_model.py +98 -0
  6. css/crawling.css +133 -0
  7. css/history.css +230 -0
  8. css/index.css +799 -0
  9. css/style.css +3759 -0
  10. css/support.css +141 -0
  11. dashboard.html +81 -0
  12. data.html +93 -0
  13. history.html +47 -0
  14. img/logo.svg +10 -0
  15. index.html +249 -0
  16. js/analytics.js +369 -0
  17. js/app.js +868 -0
  18. js/chart.js +0 -0
  19. js/cleaning.js +135 -0
  20. js/dashboard.js +180 -0
  21. js/data.js +213 -0
  22. js/demo.js +72 -0
  23. js/history.js +130 -0
  24. js/index.js +122 -0
  25. js/shared.js +897 -0
  26. js/support.js +6 -0
  27. js/tweets.js +289 -0
  28. js/upload.js +217 -0
  29. model/config.json +55 -0
  30. model/onnx/model.onnx +3 -0
  31. model/tokenizer.json +0 -0
  32. model/tokenizer_config.json +14 -0
  33. requirements.txt +4 -0
  34. support.html +112 -0
  35. tweets.html +101 -0
  36. upload.html +96 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: Sentimeter
3
- emoji:
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.9.0
8
  app_file: app.py
 
1
  ---
2
  title: Sentimeter
3
+ emoji: 🏃
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.9.0
8
  app_file: app.py
analytics.html ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1.0" />
5
+ <title>Analytics — SentiMeter</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" /><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
8
+ <script src="js/chart.js"></script>
9
+ <link rel="stylesheet" href="css/style.css" />
10
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
11
+ </head>
12
+ <body>
13
+ <div class="layout">
14
+ <div id="sidebar"></div>
15
+ <div class="main">
16
+ <div class="topbar">
17
+ <div class="topbar-title">Analytics Lengkap</div>
18
+ <div id="topbarMeta" class="topbar-sub"></div>
19
+ </div>
20
+ <div class="page-body">
21
+
22
+ <div class="sec-head">
23
+ <div><div class="sec-title">Visualisasi Data</div><div class="sec-sub">15 grafik & tabel analitik komprehensif</div></div>
24
+ </div>
25
+
26
+ <!-- Row 1: Donut + Time trend -->
27
+ <div class="chart-grid" style="margin-bottom:14px">
28
+ <div class="card card-body">
29
+ <div class="card-head"><div class="card-title">1. Distribusi Sentimen</div><span class="card-badge">Donut</span></div>
30
+ <div class="chart-wrap chart-wrap-sm"><canvas id="c1"></canvas></div>
31
+ <div class="legend-row" id="legend1"></div>
32
+ </div>
33
+ <div class="card card-body">
34
+ <div class="card-head"><div class="card-title">2. Tren Sentimen per Jam</div><span class="card-badge">Area</span></div>
35
+ <div class="chart-wrap"><canvas id="c2"></canvas></div>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Row 2: Stacked topic + location horiz -->
40
+ <div class="chart-grid" style="margin-bottom:14px">
41
+ <div class="card card-body">
42
+ <div class="card-head"><div class="card-title">3. Sentimen per Topik</div><span class="card-badge">Batang Berlapis</span></div>
43
+ <div class="chart-wrap"><canvas id="c3"></canvas></div>
44
+ </div>
45
+ <div class="card card-body">
46
+ <div class="card-head"><div class="card-title">4. Distribusi Lokasi</div><span class="card-badge">Batang Horiz.</span></div>
47
+ <div class="chart-wrap"><canvas id="c4"></canvas></div>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- Row 3: User activity + Grouped topic score -->
52
+ <div class="chart-grid" style="margin-bottom:14px">
53
+ <div class="card card-body">
54
+ <div class="card-head"><div class="card-title">5. Pengguna Paling Aktif</div><span class="card-badge">Batang Horiz.</span></div>
55
+ <div class="chart-wrap"><canvas id="c5"></canvas></div>
56
+ </div>
57
+ <div class="card card-body">
58
+ <div class="card-head"><div class="card-title">6. Avg Confidence per Topik</div><span class="card-badge">Batang Berkelompok</span></div>
59
+ <div class="chart-wrap"><canvas id="c8"></canvas></div>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Row 4: Hourly heatmap-bar (full width) -->
64
+ <div class="chart-grid" style="margin-bottom:14px">
65
+ <div class="card card-body chart-full">
66
+ <div class="card-head"><div class="card-title">7. Aktivitas per Jam (Heatmap)</div><span class="card-badge">Batang</span></div>
67
+ <div class="chart-wrap"><canvas id="c12"></canvas></div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- ━━━ NEW SECTIONS ━━━ -->
72
+
73
+ <!-- Row 5: Language Sentiment Chart (full) ← now #8 -->
74
+ <div class="chart-grid" style="margin-bottom:14px">
75
+ <div class="card card-body chart-full">
76
+ <div class="card-head"><div class="card-title">8. Sentimen berdasarkan Bahasa</div><span class="card-badge">Batang Berlapis</span></div>
77
+ <div class="chart-wrap"><canvas id="cLang"></canvas></div>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Row 6: Top Hashtag Table + Top Topic Table -->
82
+ <div class="chart-grid" style="margin-bottom:14px">
83
+ <div class="card card-body">
84
+ <div class="card-head"><div class="card-title">9. Top 10 Hashtag Teratas</div><span class="card-badge">Tabel</span></div>
85
+ <div class="analytics-table-wrap">
86
+ <table class="analytics-table" id="tblHashtag">
87
+ <thead><tr><th>#</th><th>Hashtag</th><th>Jumlah</th><th>Distribusi</th></tr></thead>
88
+ <tbody id="tblHashtagBody"></tbody>
89
+ </table>
90
+ <div class="empty-state" id="tblHashtagEmpty" style="display:none;margin-top:0;border:none">
91
+ <div class="empty-state-title">Tidak ada hashtag</div>
92
+ <div class="empty-state-desc">Belum ada data hashtag yang ditemukan.</div>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ <div class="card card-body">
97
+ <div class="card-head"><div class="card-title">10. Top 10 Topik Teratas</div><span class="card-badge">Tabel</span></div>
98
+ <div class="analytics-table-wrap">
99
+ <table class="analytics-table" id="tblTopic">
100
+ <thead><tr><th>#</th><th>Topik</th><th>Total</th><th class="tc-pos">✦ Pos</th><th class="tc-neg">✦ Neg</th><th class="tc-neu">✦ Net</th></tr></thead>
101
+ <tbody id="tblTopicBody"></tbody>
102
+ </table>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ <!-- Row 7: Sentiment per Hashtag Table (full) -->
108
+ <div class="chart-grid" style="margin-bottom:14px">
109
+ <div class="card card-body chart-full">
110
+ <div class="card-head"><div class="card-title">11. Sentimen per Hashtag Teratas</div><span class="card-badge">Tabel Detail</span></div>
111
+ <div class="analytics-table-wrap">
112
+ <table class="analytics-table" id="tblHashtagSent">
113
+ <thead><tr><th>#</th><th>Hashtag</th><th>Total</th><th class="tc-pos">Positif</th><th class="tc-neg">Negatif</th><th class="tc-neu">Netral</th><th>Dominan</th><th>Bar Distribusi</th></tr></thead>
114
+ <tbody id="tblHashtagSentBody"></tbody>
115
+ </table>
116
+ <div class="empty-state" id="tblHashtagSentEmpty" style="display:none;margin-top:0;border:none">
117
+ <div class="empty-state-title">Tidak ada hashtag</div>
118
+ <div class="empty-state-desc">Belum ada data hashtag yang ditemukan.</div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Row 8: Common Words (3 columns) -->
125
+ <div class="chart-grid-3" style="margin-bottom:14px">
126
+ <div class="card card-body">
127
+ <div class="card-head"><div class="card-title">12. Kata Umum — Positif</div><span class="card-badge" style="background:var(--pos-d);color:var(--pos);border-color:transparent">Positif</span></div>
128
+ <div class="word-tag-cloud" id="wordsPos"></div>
129
+ </div>
130
+ <div class="card card-body">
131
+ <div class="card-head"><div class="card-title">13. Kata Umum — Netral</div><span class="card-badge" style="background:var(--neu-d);color:var(--neu);border-color:transparent">Netral</span></div>
132
+ <div class="word-tag-cloud" id="wordsNeu"></div>
133
+ </div>
134
+ <div class="card card-body">
135
+ <div class="card-head"><div class="card-title">14. Kata Umum — Negatif</div><span class="card-badge" style="background:var(--neg-d);color:var(--neg);border-color:transparent">Negatif</span></div>
136
+ <div class="word-tag-cloud" id="wordsNeg"></div>
137
+ </div>
138
+ </div>
139
+
140
+ </div>
141
+ </div>
142
+ </div>
143
+ <script src="js/shared.js"></script>
144
+ <script src="js/analytics.js"></script>
145
+ </body>
146
+ </html>
app.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SentiMeter — IndoBERT Sentiment Analysis Backend
3
+ Uses mdhugol/indonesia-bert-sentiment-classification
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import traceback
9
+ from flask import Flask, request, jsonify, send_from_directory
10
+ from flask_cors import CORS
11
+
12
+ app = Flask(__name__, static_folder='.', static_url_path='')
13
+ CORS(app)
14
+
15
+ # ─── Model Loading ────────────────────────────────────
16
+ MODEL_NAME = "mdhugol/indonesia-bert-sentiment-classification"
17
+ LABEL_MAP = {
18
+ 'LABEL_0': 'Positif',
19
+ 'LABEL_1': 'Netral',
20
+ 'LABEL_2': 'Negatif',
21
+ }
22
+
23
+ sentiment_pipeline = None
24
+ model_error = None
25
+
26
+
27
+ def load_model():
28
+ """Load the IndoBERT sentiment model. Returns True on success."""
29
+ global sentiment_pipeline, model_error
30
+ try:
31
+ print(f"[IndoBERT] Loading model: {MODEL_NAME} ...")
32
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification, pipeline
33
+
34
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
35
+ model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME)
36
+ sentiment_pipeline = pipeline(
37
+ "sentiment-analysis",
38
+ model=model,
39
+ tokenizer=tokenizer,
40
+ truncation=True,
41
+ max_length=512,
42
+ )
43
+ model_error = None
44
+ print("[IndoBERT] Model loaded successfully!")
45
+ return True
46
+ except Exception as e:
47
+ model_error = str(e)
48
+ sentiment_pipeline = None
49
+ print(f"[IndoBERT] ERROR loading model: {e}", file=sys.stderr)
50
+ traceback.print_exc()
51
+ return False
52
+
53
+
54
+ # ─── API Routes ───────────────────────────────────────
55
+
56
+ @app.route('/api/health', methods=['GET'])
57
+ def health():
58
+ """Check if the model is loaded and ready."""
59
+ if sentiment_pipeline is not None:
60
+ return jsonify({"status": "ok", "model": MODEL_NAME})
61
+ else:
62
+ return jsonify({
63
+ "status": "error",
64
+ "model": MODEL_NAME,
65
+ "error": model_error or "Model not loaded"
66
+ }), 503
67
+
68
+
69
+ @app.route('/api/sentiment', methods=['POST'])
70
+ def analyze_sentiment():
71
+ """
72
+ Analyze sentiment for a batch of texts.
73
+ Expects JSON: { "texts": ["text1", "text2", ...] }
74
+ Returns JSON: [ {"label": "Positif", "score": 0.95}, ... ]
75
+ """
76
+ if sentiment_pipeline is None:
77
+ return jsonify({
78
+ "error": True,
79
+ "message": f"Model IndoBERT gagal dimuat: {model_error or 'Unknown error'}. "
80
+ "Pastikan koneksi internet aktif dan dependensi sudah terinstal.",
81
+ }), 503
82
+
83
+ data = request.get_json(silent=True)
84
+ if not data or 'texts' not in data:
85
+ return jsonify({"error": True, "message": "Request harus berisi field 'texts'."}), 400
86
+
87
+ texts = data['texts']
88
+ if not isinstance(texts, list) or len(texts) == 0:
89
+ return jsonify({"error": True, "message": "'texts' harus berupa array string yang tidak kosong."}), 400
90
+
91
+ # Limit batch size to prevent OOM
92
+ MAX_BATCH = 64
93
+ results = []
94
+
95
+ try:
96
+ for i in range(0, len(texts), MAX_BATCH):
97
+ batch = texts[i:i + MAX_BATCH]
98
+ # Ensure all texts are non-empty strings
99
+ batch = [str(t).strip() or "." for t in batch]
100
+ preds = sentiment_pipeline(batch)
101
+ for pred in preds:
102
+ label = LABEL_MAP.get(pred['label'], pred['label'])
103
+ results.append({
104
+ "label": label,
105
+ "score": round(pred['score'], 4),
106
+ })
107
+ except Exception as e:
108
+ return jsonify({
109
+ "error": True,
110
+ "message": f"Error saat proses sentimen: {str(e)}"
111
+ }), 500
112
+
113
+ return jsonify(results)
114
+
115
+
116
+ # ─── Static File Serving ─────────────────────────────
117
+
118
+ @app.route('/')
119
+ def serve_index():
120
+ return send_from_directory('.', 'index.html')
121
+
122
+
123
+ @app.route('/<path:path>')
124
+ def serve_static(path):
125
+ if os.path.isfile(path):
126
+ return send_from_directory('.', path)
127
+ return send_from_directory('.', 'index.html')
128
+
129
+
130
+ # ─── Main ────────────────────────────────────────────
131
+
132
+ if __name__ == '__main__':
133
+ load_model()
134
+ print("\n" + "=" * 50)
135
+ if sentiment_pipeline:
136
+ print(" SentiMeter Server — IndoBERT Ready")
137
+ else:
138
+ print(" SentiMeter Server — WARNING: Model failed to load!")
139
+ print(f" Error: {model_error}")
140
+ print(f" Open: http://localhost:3000")
141
+ print("=" * 50 + "\n")
142
+ app.run(host='0.0.0.0', port=3000, debug=False)
cleaning.html ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1.0" />
5
+ <title>Cleaning Lab — SentiMeter</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" /><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
8
+ <script src="js/chart.js"></script>
9
+ <link rel="stylesheet" href="css/style.css" />
10
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
11
+ </head>
12
+ <body>
13
+ <div class="layout">
14
+ <div id="sidebar"></div>
15
+ <div class="main">
16
+ <div class="topbar">
17
+ <div class="topbar-title">Text Cleaning Lab</div>
18
+ <div id="topbarMeta" class="topbar-sub"></div>
19
+ </div>
20
+ <div class="page-body">
21
+ <div class="sec-head">
22
+ <div><div class="sec-title">Pipeline Pembersihan Teks</div><div class="sec-sub">9 langkah preprocessing untuk IndoBERT</div></div>
23
+ </div>
24
+
25
+ <div class="chart-grid" style="margin-bottom:14px;align-items:stretch">
26
+ <!-- Interactive Demo -->
27
+ <div class="card card-body">
28
+ <div class="card-head"><div class="card-title">Demo Interaktif</div><span class="card-badge">Live Preview</span></div>
29
+ <div class="demo-area" style="padding:0;background:transparent">
30
+ <div class="demo-label">Masukkan teks untuk dicoba:</div>
31
+ <textarea class="demo-textarea" id="demoInput" rows="4" placeholder="Contoh: @liputan6dotcom Gak sia-sia mendukung #Prabowo-Gibran! https://t.co/abc123 😍 Data ekonomi tumbuh 5.17%"></textarea>
32
+ </div>
33
+ <div style="margin-top:14px">
34
+ <div style="font-size:11px;color:var(--tx3);margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.5px">Hasil per Langkah:</div>
35
+ <div class="step-pipeline" id="stepPipeline"></div>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Pipeline Steps Config -->
40
+ <div class="card card-body">
41
+ <div class="card-head"><div class="card-title">Langkah Cleaning</div><span class="card-badge">Konfigurasi</span></div>
42
+ <div id="pipelineSteps"></div>
43
+ </div>
44
+ </div>
45
+
46
+ <!-- Dataset Stats -->
47
+ <div class="card card-body" style="margin-bottom:14px">
48
+ <div class="card-head"><div class="card-title">Statistik Cleaning Dataset</div><span class="card-badge">Seluruh Data</span></div>
49
+ <div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px" id="cleaningStats"></div>
50
+ <div class="chart-grid" style="margin:0">
51
+ <div>
52
+ <div style="font-size:11px;color:var(--tx3);margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.5px">Distribusi Pengurangan Kata (%)</div>
53
+ <div class="chart-wrap chart-wrap-sm"><canvas id="chartReduction"></canvas></div>
54
+ </div>
55
+ <div>
56
+ <div style="font-size:11px;color:var(--tx3);margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.5px">Top 15 Kata Tersering (Setelah Cleaning)</div>
57
+ <div class="chart-wrap chart-wrap-sm"><canvas id="chartWords"></canvas></div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Before/After Table -->
63
+ <div class="card card-body">
64
+ <div class="card-head">
65
+ <div class="card-title">Perbandingan Sebelum & Sesudah Cleaning</div>
66
+ <span class="card-badge">Sampel 20 Pertama</span>
67
+ </div>
68
+ <div class="table-wrap">
69
+ <table class="data-table">
70
+ <thead><tr>
71
+ <th style="width:36px">No</th>
72
+ <th>Teks Asli</th>
73
+ <th>Teks Bersih</th>
74
+ <th style="width:70px">Kata Awal</th>
75
+ <th style="width:70px">Kata Akhir</th>
76
+ <th style="width:80px">Reduksi</th>
77
+ </tr></thead>
78
+ <tbody id="cleanTableBody"></tbody>
79
+ </table>
80
+ </div>
81
+ </div>
82
+
83
+ </div>
84
+ </div>
85
+ </div>
86
+ <script src="js/shared.js"></script>
87
+ <script src="js/cleaning.js"></script>
88
+ </body>
89
+ </html>
convert_model.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One-time script to convert IndoBERT sentiment model to ONNX format (quantized).
3
+ Run this once: python convert_model.py
4
+ After conversion, the 'model/onnx/' folder will contain the quantized ONNX model
5
+ that can be loaded directly in the browser via ONNX Runtime Web.
6
+ """
7
+ import os
8
+ import json
9
+ import torch
10
+ import shutil
11
+ from pathlib import Path
12
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification, AutoConfig
13
+
14
+ MODEL_ID = "mdhugol/indonesia-bert-sentiment-classification"
15
+ OUTPUT_DIR = Path("./model")
16
+ ONNX_DIR = OUTPUT_DIR / "onnx"
17
+
18
+ # Clean up previous conversion
19
+ if ONNX_DIR.exists():
20
+ shutil.rmtree(ONNX_DIR)
21
+ ONNX_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+ print(f"[1/5] Loading model: {MODEL_ID}")
24
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
25
+ model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID)
26
+ config = AutoConfig.from_pretrained(MODEL_ID)
27
+ model.eval()
28
+
29
+ print("[2/5] Creating dummy input for ONNX export...")
30
+ dummy_text = "Ini adalah contoh kalimat untuk testing"
31
+ inputs = tokenizer(dummy_text, return_tensors="pt", padding="max_length", max_length=128, truncation=True)
32
+
33
+ print("[3/5] Exporting to ONNX (with embedded weights)...")
34
+ raw_onnx_path = str(ONNX_DIR / "model_raw.onnx")
35
+
36
+ # Use opset 14, disable external data to embed weights in the ONNX file
37
+ with torch.no_grad():
38
+ torch.onnx.export(
39
+ model,
40
+ (inputs["input_ids"], inputs["attention_mask"], inputs["token_type_ids"]),
41
+ raw_onnx_path,
42
+ input_names=["input_ids", "attention_mask", "token_type_ids"],
43
+ output_names=["logits"],
44
+ dynamic_axes={
45
+ "input_ids": {0: "batch_size", 1: "sequence"},
46
+ "attention_mask": {0: "batch_size", 1: "sequence"},
47
+ "token_type_ids": {0: "batch_size", 1: "sequence"},
48
+ "logits": {0: "batch_size"},
49
+ },
50
+ opset_version=14,
51
+ do_constant_folding=True,
52
+ )
53
+
54
+ raw_size = os.path.getsize(raw_onnx_path)
55
+ print(f" Raw ONNX size: {raw_size/1024/1024:.1f} MB")
56
+
57
+ print("[4/5] Quantizing to int8 (dynamic quantization)...")
58
+ from onnxruntime.quantization import quantize_dynamic, QuantType
59
+
60
+ quant_onnx_path = str(ONNX_DIR / "model_quantized.onnx")
61
+ quantize_dynamic(
62
+ raw_onnx_path,
63
+ quant_onnx_path,
64
+ weight_type=QuantType.QUInt8,
65
+ )
66
+ quant_size = os.path.getsize(quant_onnx_path)
67
+ print(f" Quantized ONNX size: {quant_size/1024/1024:.1f} MB")
68
+ print(f" Compression ratio: {raw_size/quant_size:.1f}x")
69
+
70
+ # Remove the raw model, keep quantized
71
+ os.remove(raw_onnx_path)
72
+ # Rename quantized to model.onnx
73
+ final_path = str(ONNX_DIR / "model.onnx")
74
+ os.rename(quant_onnx_path, final_path)
75
+
76
+ # Remove external data files if they exist
77
+ for f in ONNX_DIR.glob("*.data"):
78
+ os.remove(f)
79
+
80
+ print("[5/5] Saving tokenizer and config files...")
81
+ # Save tokenizer files
82
+ tokenizer.save_pretrained(str(OUTPUT_DIR))
83
+
84
+ # Update config with label mapping
85
+ config_data = config.to_dict()
86
+ config_data["id2label"] = {"0": "Positif", "1": "Netral", "2": "Negatif"}
87
+ config_data["label2id"] = {"Positif": 0, "Netral": 1, "Negatif": 2}
88
+ with open(str(OUTPUT_DIR / "config.json"), "w") as f:
89
+ json.dump(config_data, f, indent=2)
90
+
91
+ print(f"\nDone! Model saved to '{OUTPUT_DIR}/'")
92
+ print("\nFiles created:")
93
+ for root, dirs, files in os.walk(str(OUTPUT_DIR)):
94
+ for f in sorted(files):
95
+ path = os.path.join(root, f)
96
+ size = os.path.getsize(path)
97
+ print(f" {os.path.relpath(path, str(OUTPUT_DIR))} ({size/1024/1024:.1f} MB)" if size > 1024*1024 else f" {os.path.relpath(path, str(OUTPUT_DIR))} ({size/1024:.1f} KB)")
98
+ print("\nYou can now run the website with just: npx serve . -p 3000")
css/crawling.css ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .crawling-container {
2
+ max-width: 1200px;
3
+ margin: 0 auto;
4
+ padding-bottom: 60px;
5
+ }
6
+
7
+ .scraper-card {
8
+ background: var(--bg-card);
9
+ border: none;
10
+ border-radius: var(--r3);
11
+ padding: 48px 60px;
12
+ position: relative;
13
+ overflow: hidden;
14
+ box-shadow: var(--shadow-sm);
15
+ margin-bottom: 32px;
16
+ transition: all var(--t-fast);
17
+ }
18
+
19
+ .scraper-card.featured {
20
+ border: none;
21
+ background: linear-gradient(165deg, var(--bg-card), var(--bg-card2));
22
+ box-shadow: var(--shadow-md), 0 20px 40px rgba(0,0,0,0.03);
23
+ }
24
+
25
+
26
+ .scraper-header {
27
+ margin-bottom: 24px;
28
+ }
29
+
30
+ .scraper-name {
31
+ font-size: 26px;
32
+ font-weight: 800;
33
+ color: var(--tx1);
34
+ letter-spacing: -0.8px;
35
+ margin: 0;
36
+ }
37
+
38
+ .scraper-domain {
39
+ font-size: 13px;
40
+ color: var(--acc);
41
+ font-weight: 700;
42
+ margin-top: 4px;
43
+ }
44
+
45
+ .scraper-desc {
46
+ font-size: 14.5px;
47
+ color: var(--tx2);
48
+ line-height: 1.8;
49
+ margin-bottom: 32px;
50
+ }
51
+
52
+ .scraper-features {
53
+ display: grid;
54
+ grid-template-columns: repeat(3, 1fr);
55
+ gap: 20px;
56
+ margin-bottom: 40px;
57
+ }
58
+
59
+ .feat-item {
60
+ padding: 16px 20px;
61
+ background: var(--bg-card2);
62
+ border: none;
63
+ border-radius: 12px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 6px;
67
+ transition: all var(--t-fast);
68
+ }
69
+
70
+ .feat-item:hover {
71
+ border-color: var(--acc);
72
+ background: var(--bg-card);
73
+ transform: translateY(-2px);
74
+ }
75
+
76
+ .feat-title {
77
+ font-size: 14px;
78
+ font-weight: 800;
79
+ color: var(--tx1);
80
+ letter-spacing: -0.2px;
81
+ }
82
+
83
+ .feat-desc {
84
+ font-size: 12px;
85
+ color: var(--tx3);
86
+ line-height: 1.6;
87
+ }
88
+
89
+ .scraper-actions {
90
+ display: flex;
91
+ justify-content: center;
92
+ }
93
+
94
+ .scraper-main-btn {
95
+ height: 44px;
96
+ padding: 0 36px;
97
+ font-size: 13.5px;
98
+ font-weight: 700;
99
+ border-radius: 10px;
100
+ transition: all var(--t-fast);
101
+ }
102
+
103
+ /* Info Banner */
104
+ .info-banner {
105
+ padding: 24px 32px;
106
+ background: var(--bg-card2);
107
+ border: none;
108
+ border-radius: var(--r2);
109
+ }
110
+
111
+ .info-content h4 {
112
+ margin: 0 0 8px 0;
113
+ font-size: 15px;
114
+ font-weight: 800;
115
+ color: var(--tx1);
116
+ letter-spacing: -0.2px;
117
+ }
118
+
119
+ .info-content p {
120
+ margin: 0;
121
+ font-size: 13px;
122
+ color: var(--tx3);
123
+ line-height: 1.7;
124
+ }
125
+
126
+ @media (max-width: 768px) {
127
+ .scraper-features {
128
+ grid-template-columns: 1fr;
129
+ }
130
+ .scraper-card {
131
+ padding: 32px 24px;
132
+ }
133
+ }
css/history.css ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════
2
+ SentiMeter — history.css
3
+ Riwayat Analisis Page Styles
4
+ ═══════════════════════════════════════════════════ */
5
+
6
+ /* ── History Header Badge ── */
7
+ .hist-count-badge {
8
+ display: inline-flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ background: var(--acc-d);
12
+ color: var(--acc);
13
+ font-size: 13px;
14
+ font-weight: 700;
15
+ padding: 2px 10px;
16
+ border-radius: 100px;
17
+ margin-left: 8px;
18
+ vertical-align: middle;
19
+ border: 1px solid var(--acc-d2);
20
+ }
21
+
22
+ /* ── History Header & Empty State ── */
23
+ .history-empty {
24
+ text-align: center;
25
+ padding: 100px 32px;
26
+ background: var(--bg-card);
27
+ border: 1px solid var(--border);
28
+ border-radius: var(--r3);
29
+ display: flex;
30
+ flex-direction: column;
31
+ align-items: center;
32
+ justify-content: center; /* Center vertically if needed */
33
+ position: relative;
34
+ overflow: hidden;
35
+ box-shadow: var(--shadow-sm);
36
+ }
37
+
38
+ /* Subtle glow effect for empty state */
39
+ .history-empty::before {
40
+ content: '';
41
+ position: absolute;
42
+ top: 0; left: 0; width: 100%; height: 100%;
43
+ background: radial-gradient(circle at center, var(--acc-d) 0%, transparent 70%);
44
+ opacity: 0.2;
45
+ pointer-events: none;
46
+ }
47
+
48
+ .history-empty h3 {
49
+ font-size: 19px;
50
+ font-weight: 800;
51
+ margin-bottom: 12px;
52
+ color: var(--tx1);
53
+ letter-spacing: -0.4px;
54
+ position: relative;
55
+ }
56
+
57
+ .history-empty p {
58
+ color: var(--tx2);
59
+ font-size: 13.5px;
60
+ margin-bottom: 32px;
61
+ max-width: 380px;
62
+ line-height: 1.6;
63
+ position: relative;
64
+ }
65
+
66
+ .empty-cta {
67
+ min-width: 200px;
68
+ position: relative;
69
+ font-size: 13.5px;
70
+ }
71
+
72
+ /* ── Premium History Card ── */
73
+ .history-container {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 24px; /* More compact gap */
77
+ }
78
+
79
+ .hist-card {
80
+ background: var(--bg-card);
81
+ border: 1px solid var(--border);
82
+ border-radius: var(--r2); /* Neater radius */
83
+ padding: 16px 20px; /* More compact padding */
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: 14px;
87
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
88
+ animation: revealUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
89
+ position: relative;
90
+ box-shadow: var(--shadow-sm);
91
+ }
92
+
93
+ .hist-card:hover {
94
+ box-shadow: var(--shadow-lg);
95
+ border-color: var(--acc-d2);
96
+ }
97
+
98
+ .hist-card-top {
99
+ display: flex;
100
+ justify-content: space-between;
101
+ align-items: flex-start;
102
+ gap: 20px;
103
+ }
104
+
105
+ .hist-filename {
106
+ font-size: 14.5px; /* Reduced font size */
107
+ font-weight: 700;
108
+ color: var(--tx1);
109
+ font-size: 13.5px; /* Neater size */
110
+ font-weight: 700;
111
+ color: var(--tx1);
112
+ margin-bottom: 2px;
113
+ line-height: 1.2;
114
+ }
115
+
116
+ .hist-meta {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 12px;
120
+ font-size: 11.5px; /* Smaller meta */
121
+ color: var(--tx3);
122
+ }
123
+
124
+ .hist-actions {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 8px;
128
+ flex-shrink: 0;
129
+ }
130
+
131
+ /* ── Visual Sentiment Bar ── */
132
+ .hist-visual {
133
+ margin-top: 0px;
134
+ }
135
+
136
+ .hist-bar-outer {
137
+ height: 6px; /* Slightly thinner */
138
+ background: var(--bg-card2);
139
+ border-radius: 100px;
140
+ display: flex;
141
+ overflow: hidden;
142
+ margin-bottom: 10px;
143
+ border: 1px solid var(--border-s);
144
+ }
145
+
146
+ .hist-bar-segment {
147
+ height: 100%;
148
+ transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1);
149
+ }
150
+
151
+ .hist-bar-segment.pos { background: var(--pos); }
152
+ .hist-bar-segment.neu { background: var(--neu); }
153
+ .hist-bar-segment.neg { background: var(--neg); }
154
+
155
+ /* ── Stats Row ── */
156
+ .hist-stats {
157
+ display: flex;
158
+ flex-wrap: wrap;
159
+ align-items: center;
160
+ gap: 20px;
161
+ padding: 12px 16px; /* More compact stats area */
162
+ background: var(--bg-card2);
163
+ border-radius: var(--r2);
164
+ border: 1px solid var(--border-s);
165
+ margin-top: 2px;
166
+ }
167
+
168
+ .hist-stat-box {
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: 0px;
172
+ }
173
+
174
+ .hist-stat-label {
175
+ font-size: 10.5px; /* Tiny, elegant labels */
176
+ color: var(--tx3);
177
+ font-weight: 600;
178
+ text-transform: uppercase;
179
+ letter-spacing: 0.05em;
180
+ }
181
+
182
+ .hist-stat-val {
183
+ color: var(--tx1);
184
+ display: flex;
185
+ align-items: baseline;
186
+ gap: 4px;
187
+ }
188
+
189
+ .hist-stat-unit {
190
+ font-size: 10px;
191
+ font-weight: 500;
192
+ color: var(--tx3);
193
+ }
194
+
195
+ .hist-stat-val.pos { color: var(--pos); }
196
+ .hist-stat-val.neg { color: var(--neg); }
197
+ .hist-stat-val.neu { color: var(--neu); }
198
+
199
+ /* ── Responsive ── */
200
+ @media (max-width: 768px) {
201
+ .history-header {
202
+ padding: 24px 16px;
203
+ }
204
+ .hist-card {
205
+ padding: 16px;
206
+ }
207
+ .hist-card-top {
208
+ flex-direction: column;
209
+ gap: 12px;
210
+ }
211
+ .hist-meta {
212
+ flex-wrap: wrap;
213
+ gap: 8px;
214
+ }
215
+ .hist-actions {
216
+ width: 100%;
217
+ justify-content: stretch;
218
+ flex-direction: row; /* stack side-by-side but take full width */
219
+ margin-top: 4px;
220
+ }
221
+ .hist-actions .btn {
222
+ flex: 1;
223
+ justify-content: center;
224
+ }
225
+ .hist-stats {
226
+ gap: 12px;
227
+ padding: 12px;
228
+ justify-content: flex-start;
229
+ }
230
+ }
css/index.css ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════
2
+ intro.css — SentiMeter Intro Page (Simplified)
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ .ih-hero {
6
+ position: relative;
7
+ padding: 100px 48px 60px;
8
+ text-align: center;
9
+ overflow: hidden;
10
+ }
11
+
12
+ .ih-hero-glow {
13
+ /* Removed to keep the background perfectly clean and prevent any cutoff lines */
14
+ display: none;
15
+ }
16
+
17
+ .ih-hero-inner {
18
+ position: relative;
19
+ z-index: 1;
20
+ max-width: 700px;
21
+ margin: 0 auto;
22
+ }
23
+
24
+ /* Badge */
25
+ .ih-badge {
26
+ display: inline-block;
27
+ padding: 5px 16px;
28
+ background: rgba(108, 143, 255, 0.1);
29
+ border: 1px solid rgba(108, 143, 255, 0.25);
30
+ border-radius: 100px;
31
+ font-size: 12px;
32
+ font-weight: 600;
33
+ letter-spacing: 0.3px;
34
+ color: #6c8fff;
35
+ margin-bottom: 22px;
36
+ }
37
+
38
+ /* Title */
39
+ .ih-title {
40
+ font-size: clamp(34px, 5.5vw, 64px);
41
+ font-weight: 800;
42
+ letter-spacing: -2px;
43
+ line-height: 1.1;
44
+ color: var(--tx1);
45
+ margin: 0 0 18px;
46
+ }
47
+
48
+ .ih-grad {
49
+ background: linear-gradient(135deg, #6c8fff 0%, #a78bfa 50%, #60d9f9 100%);
50
+ -webkit-background-clip: text;
51
+ -webkit-text-fill-color: transparent;
52
+ background-clip: text;
53
+ }
54
+
55
+ .gradient-text {
56
+ background: linear-gradient(135deg, #6c8fff 0%, #a78bfa 50%, #60d9f9 100%);
57
+ -webkit-background-clip: text;
58
+ -webkit-text-fill-color: transparent;
59
+ background-clip: text;
60
+ }
61
+
62
+ /* Subtitle */
63
+ .ih-sub {
64
+ font-size: 16px;
65
+ line-height: 1.7;
66
+ color: var(--tx2);
67
+ max-width: 560px;
68
+ margin: 0 auto 12px;
69
+ }
70
+
71
+ /* Disclaimer */
72
+ .ih-disclaimer {
73
+ display: flex;
74
+ align-items: flex-start;
75
+ gap: 12px;
76
+ max-width: 580px;
77
+ margin: 0 auto 32px;
78
+ padding: 14px 18px;
79
+ background: var(--bg-card2);
80
+ border: 1px solid var(--border);
81
+ border-radius: 14px;
82
+ text-align: left;
83
+ }
84
+
85
+ .ih-disclaimer svg {
86
+ width: 20px;
87
+ height: 20px;
88
+ color: var(--ora);
89
+ flex-shrink: 0;
90
+ margin-top: 2px;
91
+ }
92
+
93
+ .ih-disclaimer span {
94
+ font-size: 12.5px;
95
+ line-height: 1.6;
96
+ color: var(--tx2);
97
+ }
98
+
99
+ .ih-disclaimer strong {
100
+ color: var(--tx1);
101
+ font-weight: 700;
102
+ }
103
+
104
+ /* Hero Buttons */
105
+ .ih-hero-btns {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ gap: 12px;
110
+ flex-wrap: wrap;
111
+ margin-bottom: 52px;
112
+ }
113
+
114
+ /* Stats bar */
115
+ .ih-stats {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ background: var(--bg-card2);
120
+ border: none;
121
+ border-radius: 20px;
122
+ padding: 24px 40px;
123
+ box-shadow: var(--shadow-sm);
124
+ gap: 0;
125
+ flex-wrap: nowrap;
126
+ }
127
+
128
+ .ih-stat {
129
+ display: flex;
130
+ flex-direction: column;
131
+ align-items: center;
132
+ padding: 0 40px;
133
+ gap: 6px;
134
+ }
135
+
136
+ .ih-stat b {
137
+ font-size: 32px;
138
+ font-weight: 800;
139
+ color: var(--tx1);
140
+ letter-spacing: -1px;
141
+ line-height: 1;
142
+ }
143
+
144
+ .ih-stat span {
145
+ font-size: 11px;
146
+ color: var(--tx3);
147
+ font-weight: 500;
148
+ text-transform: uppercase;
149
+ letter-spacing: 0.5px;
150
+ white-space: nowrap;
151
+ }
152
+
153
+ .ih-stat-sep {
154
+ width: 1px;
155
+ height: 36px;
156
+ background: var(--border);
157
+ flex-shrink: 0;
158
+ }
159
+
160
+ /* ── REVEAL ANIMATION ─────────────────────────── */
161
+ .reveal-up {
162
+ opacity: 0;
163
+ transform: translateY(24px);
164
+ transition:
165
+ opacity 0.55s cubic-bezier(0.16, 1, 0.3, 1),
166
+ transform 0.55s cubic-bezier(0.16, 1, 0.3, 1);
167
+ transition-delay: var(--d, 0ms);
168
+ }
169
+
170
+ .reveal-up.visible {
171
+ opacity: 1;
172
+ transform: translateY(0);
173
+ }
174
+
175
+ /* ── SECTIONS ─────────────────────────────────── */
176
+ .ih-section {
177
+ padding: 72px 48px;
178
+ max-width: 1120px;
179
+ margin: 0 auto;
180
+ }
181
+
182
+ .ih-sec-head {
183
+ text-align: center;
184
+ margin-bottom: 48px;
185
+ }
186
+
187
+ .ih-sec-label {
188
+ font-size: 11px;
189
+ font-weight: 700;
190
+ letter-spacing: 2px;
191
+ text-transform: uppercase;
192
+ color: #6c8fff;
193
+ margin: 0 0 8px;
194
+ }
195
+
196
+ .ih-sec-head h2 {
197
+ font-size: clamp(24px, 3.5vw, 36px);
198
+ font-weight: 800;
199
+ letter-spacing: -1px;
200
+ color: var(--tx1);
201
+ margin: 0 0 10px;
202
+ }
203
+
204
+ .ih-sec-sub {
205
+ font-size: 15px;
206
+ color: var(--tx2);
207
+ margin: 0;
208
+ }
209
+
210
+ /* ── STEPS ────────────────────────────────────── */
211
+ .ih-steps {
212
+ display: grid;
213
+ grid-template-columns: 1fr auto 1fr auto 1fr;
214
+ align-items: start;
215
+ gap: 0;
216
+ }
217
+
218
+ .ih-step {
219
+ background: var(--bg-card2);
220
+ border: none;
221
+ border-radius: 20px;
222
+ padding: 28px 24px;
223
+ transition: transform 0.2s, box-shadow 0.2s;
224
+ }
225
+
226
+ .ih-step:hover {
227
+ transform: translateY(-3px);
228
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
229
+ }
230
+
231
+ .ih-step-num {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ justify-content: center;
235
+ width: 40px;
236
+ height: 40px;
237
+ border-radius: 10px;
238
+ font-size: 15px;
239
+ font-weight: 800;
240
+ letter-spacing: -0.5px;
241
+ margin-bottom: 14px;
242
+ }
243
+
244
+ .ih-step-num.blue { background: rgba(108,143,255,0.12); color: #6c8fff; }
245
+ .ih-step-num.green { background: rgba(52,211,153,0.12); color: #34d399; }
246
+ .ih-step-num.purple { background: rgba(167,139,250,0.12); color: #a78bfa; }
247
+
248
+ .ih-step h3 {
249
+ font-size: 16px;
250
+ font-weight: 700;
251
+ color: var(--tx1);
252
+ margin: 0 0 8px;
253
+ }
254
+
255
+ .ih-step p {
256
+ font-size: 13px;
257
+ color: var(--tx2);
258
+ line-height: 1.6;
259
+ margin: 0 0 16px;
260
+ }
261
+
262
+ .ih-step-link {
263
+ font-size: 13px;
264
+ font-weight: 600;
265
+ color: #6c8fff;
266
+ text-decoration: none;
267
+ }
268
+
269
+ .ih-step-link:hover { text-decoration: underline; }
270
+
271
+ .ih-step-chevron {
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: center;
275
+ padding: 0 16px;
276
+ font-size: 28px;
277
+ color: var(--tx3);
278
+ opacity: 0.35;
279
+ margin-top: 60px;
280
+ }
281
+
282
+ /* ── XSCRAPER ─────────────────────────────────── */
283
+ .ih-xs-section {
284
+ padding-top: 0;
285
+ }
286
+
287
+ .ih-xs-card {
288
+ display: grid;
289
+ grid-template-columns: 1fr 380px;
290
+ gap: 48px;
291
+ background: var(--bg-card2);
292
+ border: none;
293
+ border-radius: 24px;
294
+ padding: 44px 44px;
295
+ position: relative;
296
+ overflow: hidden;
297
+ }
298
+
299
+ .ih-xs-card::before {
300
+ content: '';
301
+ position: absolute;
302
+ inset: 0;
303
+ background: radial-gradient(ellipse 60% 80% at 100% 50%, rgba(108,143,255,0.05) 0%, transparent 60%);
304
+ pointer-events: none;
305
+ }
306
+
307
+ .ih-xs-badge {
308
+ display: inline-block;
309
+ font-size: 11px;
310
+ font-weight: 700;
311
+ letter-spacing: 1px;
312
+ text-transform: uppercase;
313
+ color: #fbbf24;
314
+ background: rgba(251,191,36,0.1);
315
+ border: 1px solid rgba(251,191,36,0.2);
316
+ border-radius: 100px;
317
+ padding: 4px 14px;
318
+ margin-bottom: 20px;
319
+ }
320
+
321
+ .ih-xs-brand {
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 14px;
325
+ margin-bottom: 16px;
326
+ }
327
+
328
+ /* Logo removed */
329
+
330
+ .ih-xs-name {
331
+ font-size: 28px;
332
+ font-weight: 800;
333
+ letter-spacing: -1px;
334
+ color: var(--tx1);
335
+ margin: 0 0 2px;
336
+ }
337
+
338
+ .ih-xs-url {
339
+ font-size: 13px;
340
+ color: #6c8fff;
341
+ font-weight: 500;
342
+ margin: 0;
343
+ }
344
+
345
+ .ih-xs-desc {
346
+ font-size: 14px;
347
+ color: var(--tx2);
348
+ line-height: 1.7;
349
+ margin: 0 0 20px;
350
+ }
351
+
352
+ .ih-xs-list {
353
+ list-style: none;
354
+ padding: 0;
355
+ margin: 0 0 24px;
356
+ display: flex;
357
+ flex-direction: column;
358
+ gap: 8px;
359
+ }
360
+
361
+ .ih-xs-list li {
362
+ font-size: 13px;
363
+ color: var(--tx2);
364
+ padding-left: 18px;
365
+ position: relative;
366
+ }
367
+
368
+ .ih-xs-list li::before {
369
+ content: '✓';
370
+ position: absolute;
371
+ left: 0;
372
+ color: #34d399;
373
+ font-weight: 700;
374
+ font-size: 12px;
375
+ }
376
+
377
+ .ih-xs-actions {
378
+ display: flex;
379
+ align-items: center;
380
+ gap: 12px;
381
+ }
382
+
383
+ .ih-xs-url-chip {
384
+ font-size: 12px;
385
+ color: var(--tx3);
386
+ font-family: monospace;
387
+ background: var(--surface);
388
+ border: 1px solid var(--border);
389
+ padding: 8px 14px;
390
+ border-radius: 8px;
391
+ }
392
+
393
+ /* CSV Preview */
394
+ .ih-xs-right {
395
+ display: flex;
396
+ align-items: center;
397
+ }
398
+
399
+ .ih-csv-preview {
400
+ width: 100%;
401
+ background: #0d1117;
402
+ border-radius: 12px;
403
+ border: 1px solid rgba(255,255,255,0.07);
404
+ overflow: hidden;
405
+ font-family: 'Fira Code', 'Courier New', monospace;
406
+ font-size: 11px;
407
+ }
408
+
409
+ .ih-csv-topbar {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 8px;
413
+ padding: 10px 14px;
414
+ background: rgba(255,255,255,0.03);
415
+ border-bottom: 1px solid rgba(255,255,255,0.05);
416
+ }
417
+
418
+ .ih-csv-dot {
419
+ width: 10px; height: 10px;
420
+ border-radius: 50%;
421
+ background: rgba(255,255,255,0.15);
422
+ }
423
+ .ih-csv-dot:nth-child(1) { background: #ff5f57; }
424
+ .ih-csv-dot:nth-child(2) { background: #febc2e; }
425
+ .ih-csv-dot:nth-child(3) { background: #28c840; }
426
+
427
+ .ih-csv-fname {
428
+ font-size: 11px;
429
+ color: rgba(255,255,255,0.35);
430
+ font-family: 'Inter', sans-serif;
431
+ }
432
+
433
+ .ih-csv-body {
434
+ padding: 14px;
435
+ min-height: 200px;
436
+ }
437
+
438
+ .csv-row {
439
+ display: grid;
440
+ grid-template-columns: 18px 1.6fr 1fr 0.9fr 0.6fr;
441
+ gap: 6px;
442
+ margin-bottom: 7px;
443
+ line-height: 1.5;
444
+ }
445
+
446
+ .csv-lnum { color: rgba(255,255,255,0.2); }
447
+ .csv-h { color: #ff9580; font-weight: 600; }
448
+ .csv-text { color: #c9d1d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
449
+ .csv-user { color: #79c0ff; }
450
+ .csv-date { color: #7ee787; }
451
+ .csv-num { color: #e3b341; }
452
+ .csv-cursor { display: inline-block; width: 7px; height: 13px; background: #6c8fff; border-radius: 1px; animation: blink 1s step-end infinite; vertical-align: middle; }
453
+
454
+ @keyframes blink {
455
+ 0%,50% { opacity: 1; }
456
+ 51%,100% { opacity: 0; }
457
+ }
458
+
459
+ /* ── FEATURES GRID ────────────────────────────── */
460
+ .ih-feat-grid {
461
+ display: flex;
462
+ flex-wrap: wrap;
463
+ justify-content: center;
464
+ gap: 18px;
465
+ }
466
+
467
+ .ih-feat {
468
+ flex: 1 1 300px;
469
+ max-width: 360px;
470
+ background: var(--bg-card2);
471
+ border: none;
472
+ border-radius: 20px;
473
+ padding: 26px 22px;
474
+ position: relative;
475
+ overflow: hidden;
476
+ transition: transform 0.2s, box-shadow 0.2s;
477
+ display: flex;
478
+ flex-direction: column;
479
+ }
480
+
481
+ .ih-feat:hover {
482
+ transform: translateY(-3px);
483
+ box-shadow: 0 14px 40px rgba(0,0,0,0.12);
484
+ }
485
+
486
+ /* removed colored top borders */
487
+
488
+ .ih-feat h3 {
489
+ font-size: 16px;
490
+ font-weight: 700;
491
+ color: var(--tx1);
492
+ margin: 0 0 8px;
493
+ }
494
+
495
+ .ih-feat p {
496
+ font-size: 13px;
497
+ color: var(--tx2);
498
+ line-height: 1.6;
499
+ margin: 0 0 20px;
500
+ min-height: 64px; /* Normalizes height for 3 lines of text */
501
+ }
502
+
503
+ .ih-feat ul {
504
+ list-style: none;
505
+ padding: 0;
506
+ margin: 0 0 16px;
507
+ display: flex;
508
+ flex-direction: column;
509
+ gap: 5px;
510
+ }
511
+
512
+ .ih-feat ul li {
513
+ font-size: 12px;
514
+ color: var(--tx3);
515
+ padding-left: 14px;
516
+ position: relative;
517
+ }
518
+
519
+ .ih-feat ul li::before {
520
+ content: '→';
521
+ position: absolute;
522
+ left: 0;
523
+ opacity: 0.5;
524
+ }
525
+
526
+ .ih-feat-link {
527
+ font-size: 13px;
528
+ font-weight: 600;
529
+ color: #6c8fff;
530
+ text-decoration: none;
531
+ margin-top: auto;
532
+ display: inline-block;
533
+ padding-top: 10px;
534
+ }
535
+
536
+ .ih-feat-link:hover { text-decoration: underline; }
537
+
538
+ /* ── PIPELINE ─────────────────────────────────── */
539
+ .ih-pipeline {
540
+ max-width: 800px;
541
+ margin: 0 auto;
542
+ text-align: center;
543
+ }
544
+
545
+ .ih-pipe-io {
546
+ display: inline-block;
547
+ padding: 14px 24px;
548
+ border-radius: 10px;
549
+ font-size: 13px;
550
+ font-family: monospace;
551
+ color: var(--tx1);
552
+ max-width: 600px;
553
+ text-align: left;
554
+ }
555
+
556
+ .ih-pipe-raw {
557
+ background: rgba(248,113,113,0.07);
558
+ border: 1px solid rgba(248,113,113,0.18);
559
+ }
560
+
561
+ .ih-pipe-clean {
562
+ background: rgba(52,211,153,0.07);
563
+ border: 1px solid rgba(52,211,153,0.18);
564
+ }
565
+
566
+ .ih-pipe-arrow {
567
+ font-size: 22px;
568
+ color: var(--tx3);
569
+ opacity: 0.4;
570
+ margin: 10px 0;
571
+ }
572
+
573
+ .ih-pipe-steps {
574
+ display: grid;
575
+ grid-template-columns: repeat(3, 1fr);
576
+ gap: 10px;
577
+ margin: 12px 0;
578
+ text-align: left;
579
+ }
580
+
581
+ .ih-pipe-step {
582
+ display: flex;
583
+ flex-direction: column;
584
+ gap: 2px;
585
+ background: var(--bg-card2);
586
+ border: none;
587
+ border-radius: 12px;
588
+ padding: 14px 14px 14px 48px;
589
+ position: relative;
590
+ transition: transform 0.2s;
591
+ }
592
+
593
+ .ih-pipe-step:hover { transform: translateY(-2px); }
594
+
595
+ .ih-pipe-n {
596
+ position: absolute;
597
+ left: 12px;
598
+ top: 14px;
599
+ width: 24px;
600
+ height: 24px;
601
+ background: rgba(108,143,255,0.12);
602
+ color: #6c8fff;
603
+ border-radius: 6px;
604
+ display: flex;
605
+ align-items: center;
606
+ justify-content: center;
607
+ font-size: 11px;
608
+ font-weight: 800;
609
+ }
610
+
611
+ .ih-pipe-step b {
612
+ font-size: 12px;
613
+ font-weight: 700;
614
+ color: var(--tx1);
615
+ }
616
+
617
+ .ih-pipe-step s {
618
+ font-size: 11px;
619
+ color: var(--tx3);
620
+ text-decoration: none;
621
+ }
622
+
623
+ /* ── CTA ──────────────────────────────────────── */
624
+ .ih-cta-section {
625
+ padding: 0 48px 80px;
626
+ max-width: 1120px;
627
+ margin: 0 auto;
628
+ }
629
+
630
+ .ih-cta-box {
631
+ padding: 60px 20px;
632
+ text-align: center;
633
+ }
634
+
635
+ .ih-cta-box h2 {
636
+ font-size: clamp(22px, 3.2vw, 34px);
637
+ font-weight: 800;
638
+ letter-spacing: -1px;
639
+ color: var(--tx1);
640
+ margin: 0 0 12px;
641
+ position: relative;
642
+ }
643
+
644
+ .ih-cta-box p {
645
+ font-size: 15px;
646
+ color: var(--tx2);
647
+ margin: 0 0 30px;
648
+ position: relative;
649
+ }
650
+
651
+ .ih-cta-box-btns {
652
+ display: flex;
653
+ justify-content: center;
654
+ align-items: center;
655
+ gap: 12px;
656
+ flex-wrap: wrap;
657
+ position: relative;
658
+ }
659
+
660
+ /* ── RESPONSIVE ───────────────────────────────── */
661
+ @media (max-width: 900px) {
662
+ .ih-hero {
663
+ padding: 60px 16px 40px;
664
+ }
665
+
666
+ .ih-title {
667
+ font-size: clamp(26px, 7vw, 42px);
668
+ letter-spacing: -1.2px;
669
+ margin-bottom: 14px;
670
+ line-height: 1.15;
671
+ }
672
+
673
+ .ih-sub {
674
+ font-size: 14px;
675
+ line-height: 1.6;
676
+ margin-bottom: 12px;
677
+ padding: 0 4px;
678
+ }
679
+
680
+ .ih-disclaimer {
681
+ padding: 12px;
682
+ margin-bottom: 24px;
683
+ }
684
+
685
+ .ih-disclaimer span {
686
+ font-size: 11.5px;
687
+ }
688
+
689
+ .ih-stats {
690
+ padding: 16px;
691
+ display: grid;
692
+ grid-template-columns: 1fr;
693
+ gap: 12px;
694
+ width: 100%;
695
+ max-width: 320px;
696
+ margin: 0 auto 40px;
697
+ }
698
+
699
+ .ih-stat {
700
+ padding: 10px 12px;
701
+ flex-direction: row;
702
+ justify-content: space-between;
703
+ width: 100%;
704
+ align-items: center;
705
+ }
706
+
707
+ .ih-stat b {
708
+ font-size: 20px;
709
+ }
710
+
711
+ .ih-stat span {
712
+ text-align: right;
713
+ }
714
+
715
+ .ih-stat-sep {
716
+ display: block;
717
+ width: 100%;
718
+ height: 1px;
719
+ background: var(--border-s);
720
+ }
721
+
722
+ .ih-section,
723
+ .ih-cta-section {
724
+ padding: 48px 16px;
725
+ }
726
+
727
+ .ih-steps {
728
+ grid-template-columns: 1fr;
729
+ gap: 12px;
730
+ }
731
+
732
+ .ih-step {
733
+ padding: 20px;
734
+ }
735
+
736
+ .ih-step-chevron {
737
+ display: none;
738
+ }
739
+
740
+ .ih-xs-card {
741
+ grid-template-columns: 1fr;
742
+ padding: 24px 20px;
743
+ gap: 24px;
744
+ }
745
+
746
+ .ih-xs-right {
747
+ display: none;
748
+ }
749
+
750
+ .ih-xs-name {
751
+ font-size: 22px;
752
+ }
753
+
754
+ .ih-feat-grid {
755
+ gap: 16px;
756
+ }
757
+
758
+ .ih-feat {
759
+ padding: 24px;
760
+ flex: 1 1 100%;
761
+ }
762
+
763
+ .ih-pipe-steps {
764
+ grid-template-columns: 1fr;
765
+ gap: 8px;
766
+ }
767
+
768
+ .ih-pipe-io {
769
+ font-size: 12px;
770
+ padding: 12px 16px;
771
+ }
772
+
773
+ .ih-cta-box {
774
+ padding: 32px 16px;
775
+ }
776
+ }
777
+
778
+ @media (max-width: 480px) {
779
+ .ih-hero-inner {
780
+ padding-top: 10px;
781
+ }
782
+
783
+ .ih-badge {
784
+ font-size: 11px;
785
+ padding: 4px 12px;
786
+ margin-bottom: 16px;
787
+ }
788
+
789
+ .ih-hero-btns {
790
+ flex-direction: column;
791
+ width: 100%;
792
+ gap: 10px;
793
+ }
794
+
795
+ .ih-hero-btns .btn {
796
+ width: 100%;
797
+ justify-content: center;
798
+ }
799
+ }
css/style.css ADDED
@@ -0,0 +1,3759 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════════
2
+ SentiMeter — style.css
3
+ Professional Dual-Theme Design System
4
+ ══════════════════════════════════════════════════ */
5
+
6
+ /* ── Google Font ── */
7
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
8
+
9
+ /* ── Reset ── */
10
+ *,
11
+ *::before,
12
+ *::after {
13
+ box-sizing: border-box;
14
+ margin: 0;
15
+ padding: 0;
16
+ }
17
+
18
+ html {
19
+ scroll-behavior: smooth;
20
+ font-size: 14px;
21
+ }
22
+
23
+ /* ══════════════════════════════════════════════════
24
+ THEME TOKENS — DARK (default)
25
+ ══════════════════════════════════════════════════ */
26
+ :root {
27
+ /* Surfaces */
28
+ --bg: #09090b;
29
+ --bg-raised: #0f1015;
30
+ --bg-card: #14151b;
31
+ --bg-card2: #1a1c25;
32
+ --bg-input: #1a1c25;
33
+
34
+ /* Borders */
35
+ --border: rgba(255, 255, 255, 0.08);
36
+ --border-s: rgba(255, 255, 255, 0.04);
37
+ --border-focus: rgba(108, 143, 255, 0.5);
38
+
39
+ /* Text */
40
+ --tx1: #f1f3f9;
41
+ --tx2: #92a0b8;
42
+ --tx3: #4a5568;
43
+ --tx-inv: #09090b;
44
+
45
+ /* Accent */
46
+ --acc: #6c8fff;
47
+ --acc-h: #567af5;
48
+ --acc-d: rgba(108, 143, 255, 0.1);
49
+ --acc-d2: rgba(108, 143, 255, 0.18);
50
+
51
+ /* Sentiment */
52
+ --pos: #34d399;
53
+ --pos-d: rgba(52, 211, 153, 0.1);
54
+ --neg: #f87171;
55
+ --neg-d: rgba(248, 113, 113, 0.1);
56
+ --neu: #fbbf24;
57
+ --neu-d: rgba(251, 191, 36, 0.1);
58
+
59
+ /* Extra palette */
60
+ --pur: #a78bfa;
61
+ --pur-d: rgba(167, 139, 250, 0.1);
62
+ --cya: #38bdf8;
63
+ --cya-d: rgba(56, 189, 248, 0.1);
64
+ --pin: #f472b6;
65
+ --pin-d: rgba(244, 114, 182, 0.1);
66
+ --ora: #fb923c;
67
+ --ora-d: rgba(251, 146, 60, 0.1);
68
+
69
+ /* Sidebar */
70
+ --sidebar-w: 232px;
71
+ --sidebar-bg: #0c0d13;
72
+ --sidebar-border: rgba(255, 255, 255, 0.06);
73
+ --nav-active-bg: rgba(108, 143, 255, 0.12);
74
+ --nav-active-tx: #6c8fff;
75
+ --nav-hover-bg: rgba(255, 255, 255, 0.04);
76
+
77
+ /* Topbar */
78
+ --topbar-bg: rgba(9, 9, 11, 0.82);
79
+
80
+ /* Shadow */
81
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
82
+ --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.035);
83
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.03);
84
+ --shadow-glow: 0 0 0 3px rgba(108, 143, 255, 0.15);
85
+
86
+ /* Radius */
87
+ --r1: 6px;
88
+ --r2: 10px;
89
+ --r3: 14px;
90
+ --r4: 20px;
91
+
92
+ /* Font */
93
+ --font: 'Inter', system-ui, -apple-system, sans-serif;
94
+
95
+ /* Transitions */
96
+ --t-fast: 120ms ease;
97
+ --t-normal: 200ms ease;
98
+ --t-slow: 350ms ease;
99
+
100
+ /* Scrollbar */
101
+ --sb-track: transparent;
102
+ --sb-thumb: rgba(255, 255, 255, 0.09);
103
+ --sb-thumb-h: rgba(255, 255, 255, 0.18);
104
+ }
105
+
106
+ /* ══════════════════════════════════════════════════
107
+ THEME TOKENS — LIGHT
108
+ ══════════════════════════════════════════════════ */
109
+ [data-theme="light"] {
110
+ --bg: #f8f9fc;
111
+ --bg-raised: #ffffff;
112
+ --bg-card: #ffffff;
113
+ --bg-card2: #f1f4fa;
114
+ --bg-input: #f1f4fa;
115
+
116
+ --border: rgba(0, 0, 0, 0.08);
117
+ --border-s: rgba(0, 0, 0, 0.05);
118
+ --border-focus: rgba(79, 116, 255, 0.5);
119
+
120
+ --tx1: #0f1117;
121
+ --tx2: #4a5568;
122
+ --tx3: #9aa3b2;
123
+ --tx-inv: #ffffff;
124
+
125
+ --acc: #4f74ff;
126
+ --acc-h: #3b5df5;
127
+ --acc-d: rgba(79, 116, 255, 0.08);
128
+ --acc-d2: rgba(79, 116, 255, 0.15);
129
+
130
+ --pos: #059669;
131
+ --pos-d: rgba(5, 150, 105, 0.08);
132
+ --neg: #dc2626;
133
+ --neg-d: rgba(220, 38, 38, 0.08);
134
+ --neu: #d97706;
135
+ --neu-d: rgba(217, 119, 6, 0.08);
136
+
137
+ --pur: #7c3aed;
138
+ --pur-d: rgba(124, 58, 237, 0.08);
139
+ --cya: #0284c7;
140
+ --cya-d: rgba(2, 132, 199, 0.08);
141
+ --pin: #db2777;
142
+ --pin-d: rgba(219, 39, 119, 0.08);
143
+ --ora: #ea580c;
144
+ --ora-d: rgba(234, 88, 12, 0.08);
145
+
146
+ --sidebar-w: 232px;
147
+ --sidebar-bg: #ffffff;
148
+ --sidebar-border: rgba(0, 0, 0, 0.08);
149
+ --nav-active-bg: rgba(79, 116, 255, 0.08);
150
+ --nav-active-tx: #4f74ff;
151
+ --nav-hover-bg: rgba(0, 0, 0, 0.04);
152
+
153
+ --topbar-bg: rgba(248, 249, 252, 0.88);
154
+
155
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
156
+ --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
157
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05);
158
+ --shadow-glow: 0 0 0 3px rgba(79, 116, 255, 0.15);
159
+
160
+ --sb-track: transparent;
161
+ --sb-thumb: rgba(0, 0, 0, 0.09);
162
+ --sb-thumb-h: rgba(0, 0, 0, 0.18);
163
+ }
164
+
165
+ /* ══════════════════════════════════════════════════
166
+ BASE
167
+ ════════════════════════════════════════════���═════ */
168
+ body {
169
+ font-family: var(--font);
170
+ background: var(--bg);
171
+ color: var(--tx1);
172
+ line-height: 1.6;
173
+ -webkit-font-smoothing: antialiased;
174
+ -moz-osx-font-smoothing: grayscale;
175
+ transition: background var(--t-slow), color var(--t-slow);
176
+ min-height: 100vh;
177
+ }
178
+
179
+ ::selection {
180
+ background: var(--acc-d2);
181
+ color: var(--acc);
182
+ }
183
+
184
+ a {
185
+ color: var(--acc);
186
+ text-decoration: none;
187
+ }
188
+
189
+ a:hover {
190
+ color: var(--acc-h);
191
+ }
192
+
193
+ code {
194
+ font-size: 11.5px;
195
+ background: var(--bg-card2);
196
+ padding: 2px 7px;
197
+ border-radius: var(--r1);
198
+ color: var(--acc);
199
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
200
+ border: 1px solid var(--border-s);
201
+ }
202
+
203
+ /* ── Scrollbar ── */
204
+ ::-webkit-scrollbar {
205
+ width: 5px;
206
+ height: 5px;
207
+ }
208
+
209
+ ::-webkit-scrollbar-track {
210
+ background: var(--sb-track);
211
+ }
212
+
213
+ ::-webkit-scrollbar-thumb {
214
+ background: var(--sb-thumb);
215
+ border-radius: 4px;
216
+ }
217
+
218
+ ::-webkit-scrollbar-thumb:hover {
219
+ background: var(--sb-thumb-h);
220
+ }
221
+
222
+ /* ══════════════════════════════════════════════════
223
+ LAYOUT
224
+ ══════════════════════════════════════════════════ */
225
+ .layout {
226
+ display: flex;
227
+ min-height: 100vh;
228
+ }
229
+
230
+ /* ── SIDEBAR ── */
231
+ #sidebar {
232
+ width: var(--sidebar-w);
233
+ height: 100%;
234
+ height: 100dvh;
235
+ background: var(--sidebar-bg);
236
+ border-right: 1px solid var(--sidebar-border);
237
+ position: fixed;
238
+ top: 0;
239
+ left: 0;
240
+ bottom: 0;
241
+ display: flex;
242
+ flex-direction: column;
243
+ padding: 0 10px;
244
+ z-index: 300;
245
+ transition: background var(--t-slow), border-color var(--t-slow), transform var(--t-slow);
246
+ }
247
+
248
+ .sidebar-brand {
249
+ display: flex;
250
+ align-items: center;
251
+ height: 56px;
252
+ padding: 0 16px;
253
+ gap: 12px;
254
+ border-bottom: 1px solid var(--sidebar-border);
255
+ overflow: hidden;
256
+ flex-shrink: 0;
257
+ }
258
+
259
+ .brand-mark {
260
+ width: 30px;
261
+ height: 30px;
262
+ background-color: #6c8fff;
263
+ -webkit-mask: url('../img/logo.svg') no-repeat center;
264
+ mask: url('../img/logo.svg') no-repeat center;
265
+ -webkit-mask-size: contain;
266
+ mask-size: contain;
267
+ flex-shrink: 0;
268
+ transition: background-color var(--t-fast);
269
+ }
270
+
271
+ .nav-item.active .brand-mark {
272
+ background-color: var(--acc);
273
+ }
274
+
275
+ .brand-text-wrap {
276
+ display: flex;
277
+ flex-direction: column;
278
+ white-space: nowrap;
279
+ transition: opacity var(--t-fast);
280
+ }
281
+
282
+ .brand-name {
283
+ font-size: 15px;
284
+ font-weight: 700;
285
+ letter-spacing: -0.4px;
286
+ color: var(--tx1);
287
+ }
288
+
289
+ .brand-sub {
290
+ font-size: 10px;
291
+ color: var(--tx3);
292
+ letter-spacing: 0.6px;
293
+ text-transform: uppercase;
294
+ font-weight: 500;
295
+ margin-top: 1px;
296
+ line-height: 1;
297
+ }
298
+
299
+ .sidebar-nav {
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 2px;
303
+ flex: 1;
304
+ padding-top: 4px;
305
+ overflow-y: auto;
306
+ overflow-x: hidden;
307
+ }
308
+
309
+ .nav-section-label {
310
+ font-size: 9.5px;
311
+ font-weight: 600;
312
+ text-transform: uppercase;
313
+ letter-spacing: 0.7px;
314
+ color: var(--tx3);
315
+ padding: 12px 12px 4px;
316
+ white-space: nowrap;
317
+ transition: opacity var(--t-fast);
318
+ }
319
+
320
+ .nav-item {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 10px;
324
+ padding: 9px 12px;
325
+ border-radius: var(--r2);
326
+ color: var(--tx2);
327
+ text-decoration: none;
328
+ font-size: 13px;
329
+ font-weight: 500;
330
+ transition: background var(--t-fast), color var(--t-fast);
331
+ cursor: pointer;
332
+ user-select: none;
333
+ white-space: nowrap;
334
+ overflow: hidden;
335
+ }
336
+
337
+ .nav-icon {
338
+ width: 18px;
339
+ height: 18px;
340
+ display: inline-flex;
341
+ align-items: center;
342
+ justify-content: center;
343
+ font-size: 14px;
344
+ flex-shrink: 0;
345
+ opacity: 0.7;
346
+ border-radius: 4px;
347
+ border: 1px solid transparent;
348
+ text-align: center;
349
+ font-weight: 700;
350
+ }
351
+
352
+ .nav-item.active .nav-icon {
353
+ opacity: 1;
354
+ color: var(--acc);
355
+ background: var(--bg-card);
356
+ border-color: var(--border-s);
357
+ }
358
+
359
+ .nav-item:hover:not(.locked) {
360
+ background: var(--nav-hover-bg);
361
+ color: var(--tx1);
362
+ }
363
+
364
+ .nav-item.active {
365
+ background: var(--nav-active-bg);
366
+ color: var(--nav-active-tx);
367
+ font-weight: 600;
368
+ }
369
+
370
+ .nav-item.locked {
371
+ color: var(--tx3);
372
+ cursor: not-allowed;
373
+ opacity: 0.45;
374
+ }
375
+
376
+ .nav-lock {
377
+ font-size: 11px;
378
+ margin-left: auto;
379
+ opacity: 0.6;
380
+ }
381
+
382
+ /* ── SIDEBAR TOGGLE BTN ── */
383
+ .sidebar-toggle-btn {
384
+ transition: transform var(--t-normal) !important;
385
+ }
386
+
387
+ body.sidebar-collapsed .sidebar-toggle-btn {
388
+ transform: rotate(0deg);
389
+ }
390
+
391
+ /* ── SIDEBAR OVERLAY ── */
392
+ .sidebar-overlay {
393
+ position: fixed;
394
+ inset: 0;
395
+ background: rgba(0, 0, 0, 0.4);
396
+ backdrop-filter: blur(4px);
397
+ -webkit-backdrop-filter: blur(4px);
398
+ z-index: 250;
399
+ opacity: 0;
400
+ visibility: hidden;
401
+ transition: all var(--t-normal);
402
+ }
403
+
404
+ body.sidebar-mobile-open .sidebar-overlay {
405
+ opacity: 1;
406
+ visibility: visible;
407
+ }
408
+
409
+ /* ── SIDEBAR COLLAPSED STATE ── */
410
+ body.sidebar-collapsed #sidebar {
411
+ width: 72px;
412
+ padding: 0 8px;
413
+ }
414
+
415
+ body.sidebar-collapsed .main {
416
+ margin-left: 72px;
417
+ }
418
+
419
+ body.sidebar-collapsed .brand-text-wrap,
420
+ body.sidebar-collapsed .nav-section-label,
421
+ body.sidebar-collapsed .nav-label,
422
+ body.sidebar-collapsed .nav-lock,
423
+ body.sidebar-collapsed .theme-text,
424
+ body.sidebar-collapsed .theme-toggle .toggle-track,
425
+ body.sidebar-collapsed .data-badge .badge-text {
426
+ opacity: 0;
427
+ pointer-events: none;
428
+ display: none;
429
+ }
430
+
431
+ body.sidebar-collapsed .sidebar-brand {
432
+ justify-content: center;
433
+ padding: 0;
434
+ }
435
+
436
+ body.sidebar-collapsed .nav-item {
437
+ padding: 10px;
438
+ justify-content: center;
439
+ width: 40px;
440
+ height: 40px;
441
+ margin: 0 auto 4px;
442
+ border-radius: 10px;
443
+ }
444
+
445
+ body.sidebar-collapsed .nav-icon {
446
+ margin: 0;
447
+ font-size: 18px;
448
+ opacity: 0.8;
449
+ }
450
+
451
+ body.sidebar-collapsed .nav-item.active .nav-icon {
452
+ background: transparent;
453
+ border: none;
454
+ opacity: 1;
455
+ }
456
+
457
+ body.sidebar-collapsed .theme-toggle {
458
+ justify-content: center;
459
+ padding: 0;
460
+ width: 40px;
461
+ height: 40px;
462
+ margin: 0 auto;
463
+ border-radius: 10px;
464
+ border: 1px solid var(--border-s);
465
+ }
466
+
467
+ body.sidebar-collapsed .theme-toggle .nav-icon {
468
+ opacity: 0.8;
469
+ font-size: 18px;
470
+ }
471
+
472
+ body.sidebar-collapsed .data-badge {
473
+ padding: 0;
474
+ width: 32px;
475
+ height: 32px;
476
+ border-radius: 50%;
477
+ margin: 12px auto 0;
478
+ display: flex;
479
+ align-items: center;
480
+ justify-content: center;
481
+ }
482
+
483
+ .badge-icon {
484
+ display: none;
485
+ }
486
+
487
+ body.sidebar-collapsed .badge-icon {
488
+ display: flex;
489
+ align-items: center;
490
+ justify-content: center;
491
+ width: 100%;
492
+ height: 100%;
493
+ color: inherit;
494
+ }
495
+
496
+ .sidebar-footer {
497
+ padding: 12px 0 16px;
498
+ border-top: 1px solid var(--sidebar-border);
499
+ display: flex;
500
+ flex-direction: column;
501
+ gap: 8px;
502
+ }
503
+
504
+ /* Theme toggle button */
505
+ .theme-toggle {
506
+ display: flex;
507
+ align-items: center;
508
+ justify-content: space-between;
509
+ padding: 8px 12px;
510
+ border-radius: var(--r2);
511
+ cursor: pointer;
512
+ font-size: 12px;
513
+ font-weight: 500;
514
+ color: var(--tx2);
515
+ background: transparent;
516
+ border: 1px solid var(--border);
517
+ font-family: var(--font);
518
+ width: 100%;
519
+ transition: all var(--t-fast);
520
+ }
521
+
522
+ .theme-toggle:hover {
523
+ color: var(--tx1);
524
+ border-color: var(--border-focus);
525
+ background: var(--nav-hover-bg);
526
+ }
527
+
528
+ .toggle-track {
529
+ width: 32px;
530
+ height: 18px;
531
+ border-radius: 100px;
532
+ background: var(--bg-card2);
533
+ border: 1px solid var(--border);
534
+ position: relative;
535
+ transition: background var(--t-normal);
536
+ flex-shrink: 0;
537
+ }
538
+
539
+ .toggle-track.on {
540
+ background: var(--acc);
541
+ border-color: var(--acc);
542
+ }
543
+
544
+ .toggle-thumb {
545
+ position: absolute;
546
+ width: 12px;
547
+ height: 12px;
548
+ border-radius: 50%;
549
+ background: white;
550
+ top: 2px;
551
+ left: 2px;
552
+ transition: transform var(--t-normal);
553
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
554
+ }
555
+
556
+ .toggle-track.on .toggle-thumb {
557
+ transform: translateX(14px);
558
+ }
559
+
560
+ .data-badge {
561
+ font-size: 10px;
562
+ padding: 5px 10px;
563
+ border-radius: 100px;
564
+ background: var(--pos-d);
565
+ color: var(--pos);
566
+ text-align: center;
567
+ font-weight: 600;
568
+ letter-spacing: 0.3px;
569
+ }
570
+
571
+ .data-badge.no-data {
572
+ background: var(--bg-card2);
573
+ color: var(--tx3);
574
+ }
575
+
576
+ /* ── MAIN ── */
577
+ .main {
578
+ margin-left: var(--sidebar-w);
579
+ flex: 1;
580
+ min-width: 0;
581
+ transition: margin-left var(--t-slow) ease;
582
+ }
583
+
584
+ /* ── TOPBAR ── */
585
+ .topbar {
586
+ height: 56px;
587
+ border-bottom: 1px solid var(--border-s);
588
+ display: flex;
589
+ align-items: center;
590
+ padding: 0 28px;
591
+ gap: 14px;
592
+ position: sticky;
593
+ top: 0;
594
+ background: var(--topbar-bg);
595
+ backdrop-filter: blur(24px) saturate(1.5);
596
+ -webkit-backdrop-filter: blur(24px) saturate(1.5);
597
+ z-index: 100;
598
+ transition: background var(--t-slow), border-color var(--t-slow);
599
+ }
600
+
601
+ .topbar-title {
602
+ font-size: 14px;
603
+ font-weight: 600;
604
+ color: var(--tx1);
605
+ flex: 1;
606
+ letter-spacing: -0.2px;
607
+ }
608
+
609
+ .topbar-sub {
610
+ font-size: 12px;
611
+ color: var(--tx3);
612
+ font-weight: 400;
613
+ }
614
+
615
+ /* ── PAGE BODY ── */
616
+ .page-body {
617
+ padding: 28px 32px 48px;
618
+ }
619
+
620
+ /* ══════════════════════════════════════════════════
621
+ BUTTONS
622
+ ══════════════════════════════════════════════════ */
623
+ .btn {
624
+ display: inline-flex;
625
+ align-items: center;
626
+ gap: 6px;
627
+ padding: 9px 18px;
628
+ border-radius: var(--r2);
629
+ border: none;
630
+ font-size: 13px;
631
+ font-weight: 600;
632
+ font-family: var(--font);
633
+ cursor: pointer;
634
+ transition: all var(--t-fast);
635
+ white-space: nowrap;
636
+ line-height: 1;
637
+ -webkit-user-select: none !important;
638
+ -moz-user-select: none !important;
639
+ -ms-user-select: none !important;
640
+ user-select: none !important;
641
+ }
642
+
643
+ .btn::selection,
644
+ .btn *::selection {
645
+ background: transparent !important;
646
+ color: inherit !important;
647
+ }
648
+
649
+ .btn:active {
650
+ transform: scale(0.98);
651
+ }
652
+
653
+ .btn-primary {
654
+ background: var(--acc);
655
+ color: #fff !important;
656
+ box-shadow: 0 1px 3px rgba(108, 143, 255, 0.4);
657
+ }
658
+
659
+ .btn-primary:active,
660
+ .btn-primary:focus {
661
+ color: #fff !important;
662
+ }
663
+
664
+ .btn-primary:hover {
665
+ background: var(--acc-h);
666
+ box-shadow: 0 3px 12px rgba(108, 143, 255, 0.35);
667
+ transform: translateY(-1px);
668
+ }
669
+
670
+ .btn-outline {
671
+ background: transparent;
672
+ border: 1px solid var(--border);
673
+ color: var(--tx2);
674
+ }
675
+
676
+ .btn-outline:hover {
677
+ border-color: var(--acc);
678
+ color: var(--acc);
679
+ background: var(--acc-d);
680
+ }
681
+
682
+ .btn-ghost {
683
+ background: transparent;
684
+ border: 1px solid transparent;
685
+ color: var(--tx3);
686
+ }
687
+
688
+ .btn-ghost:hover {
689
+ color: var(--tx1);
690
+ background: var(--nav-hover-bg);
691
+ }
692
+
693
+ .btn-sm {
694
+ padding: 6px 13px;
695
+ font-size: 12px;
696
+ border-radius: var(--r1);
697
+ }
698
+
699
+ .btn-xs {
700
+ padding: 4px 10px;
701
+ font-size: 11px;
702
+ border-radius: var(--r1);
703
+ }
704
+
705
+ /* ══════════════════════════════════════════════════
706
+ CARD
707
+ ══════════════════════════════════════════════════ */
708
+ .card {
709
+ background: var(--bg-card);
710
+ border: 1px solid var(--border);
711
+ border-radius: var(--r3);
712
+ box-shadow: var(--shadow-md);
713
+ transition: background var(--t-slow), border-color var(--t-slow), box-shadow var(--t-slow);
714
+ overflow: hidden; /* Prevent content leakage */
715
+ }
716
+
717
+ .card-body {
718
+ padding: 20px 22px;
719
+ }
720
+
721
+ .card-head {
722
+ display: flex;
723
+ align-items: center;
724
+ justify-content: space-between;
725
+ margin-bottom: 16px;
726
+ }
727
+
728
+ .card-title {
729
+ font-size: 13px;
730
+ font-weight: 600;
731
+ color: var(--tx1);
732
+ letter-spacing: -0.1px;
733
+ }
734
+
735
+ .card-badge {
736
+ font-size: 10px;
737
+ padding: 3px 9px;
738
+ border-radius: 100px;
739
+ background: var(--bg-card2);
740
+ color: var(--tx3);
741
+ font-weight: 500;
742
+ border: 1px solid var(--border-s);
743
+ white-space: nowrap;
744
+ }
745
+
746
+ /* ══════════════════════════════════════════════════
747
+ KPI GRID
748
+ ══════════════════════════════════════════════════ */
749
+ .kpi-grid {
750
+ display: grid;
751
+ grid-template-columns: repeat(8, 1fr);
752
+ gap: 12px;
753
+ margin-bottom: 20px;
754
+ }
755
+
756
+ .kpi {
757
+ background: var(--bg-card);
758
+ border: 1px solid var(--border);
759
+ border-radius: var(--r3);
760
+ padding: 18px 18px 16px;
761
+ position: relative;
762
+ overflow: hidden;
763
+ box-shadow: var(--shadow-md);
764
+ transition: transform var(--t-fast), box-shadow var(--t-fast), background var(--t-slow);
765
+ }
766
+
767
+ .kpi::after {
768
+ content: '';
769
+ position: absolute;
770
+ inset: 0;
771
+ border-radius: var(--r3);
772
+ opacity: 0;
773
+ transition: opacity var(--t-fast);
774
+ box-shadow: var(--shadow-glow);
775
+ pointer-events: none;
776
+ }
777
+
778
+ .kpi:hover {
779
+ transform: translateY(-2px);
780
+ box-shadow: var(--shadow-lg);
781
+ }
782
+
783
+ .kpi:hover::after {
784
+ opacity: 1;
785
+ }
786
+
787
+ /* Accent lines dihilangkan atas permintaan pengguna */
788
+
789
+ .kpi-label {
790
+ font-size: 10.5px;
791
+ font-weight: 600;
792
+ text-transform: uppercase;
793
+ letter-spacing: 0.65px;
794
+ color: var(--tx3);
795
+ margin-bottom: 10px;
796
+ }
797
+
798
+ .kpi-value {
799
+ font-size: 28px;
800
+ font-weight: 800;
801
+ letter-spacing: -1px;
802
+ line-height: 1;
803
+ color: var(--tx1);
804
+ }
805
+
806
+ .kpi-sub {
807
+ font-size: 11px;
808
+ color: var(--tx3);
809
+ margin-top: 5px;
810
+ }
811
+
812
+ .kpi-delta {
813
+ font-size: 11.5px;
814
+ font-weight: 600;
815
+ margin-top: 5px;
816
+ }
817
+
818
+ .kpi-delta.up {
819
+ color: var(--pos);
820
+ }
821
+
822
+ .kpi-delta.down {
823
+ color: var(--neg);
824
+ }
825
+
826
+ .kpi-delta.mid {
827
+ color: var(--neu);
828
+ }
829
+
830
+ /* ══════════════════════════════════════════════════
831
+ SECTION HEADER
832
+ ══════════════════════════════════════════════════ */
833
+ .sec-head {
834
+ display: flex;
835
+ align-items: flex-end;
836
+ justify-content: space-between;
837
+ margin-bottom: 20px;
838
+ flex-wrap: wrap;
839
+ gap: 12px;
840
+ }
841
+
842
+ .sec-title {
843
+ font-size: 20px;
844
+ font-weight: 700;
845
+ letter-spacing: -0.5px;
846
+ color: var(--tx1);
847
+ }
848
+
849
+ .sec-sub {
850
+ font-size: 12px;
851
+ color: var(--tx3);
852
+ margin-top: 3px;
853
+ font-weight: 400;
854
+ }
855
+
856
+ .actions {
857
+ display: flex;
858
+ gap: 8px;
859
+ align-items: center;
860
+ flex-wrap: wrap;
861
+ }
862
+
863
+ /* ── Divider ── */
864
+ .divider {
865
+ border: none;
866
+ border-top: 1px solid var(--border-s);
867
+ margin: 22px 0;
868
+ }
869
+
870
+ /* ══════════════════════════════════════════════════
871
+ BADGES
872
+ ══════════════════════════════════════════════════ */
873
+ .badge {
874
+ display: inline-flex;
875
+ align-items: center;
876
+ padding: 3px 10px;
877
+ border-radius: 100px;
878
+ font-size: 11px;
879
+ font-weight: 600;
880
+ white-space: nowrap;
881
+ letter-spacing: 0.2px;
882
+ }
883
+
884
+ .badge-pos {
885
+ background: var(--pos-d);
886
+ color: var(--pos);
887
+ }
888
+
889
+ .badge-neg {
890
+ background: var(--neg-d);
891
+ color: var(--neg);
892
+ }
893
+
894
+ .badge-neu {
895
+ background: var(--neu-d);
896
+ color: var(--neu);
897
+ }
898
+
899
+ .badge-acc {
900
+ background: var(--acc-d);
901
+ color: var(--acc);
902
+ }
903
+
904
+ /* ══════════════════════════════════════════════════
905
+ CHART GRID
906
+ ══════════════════════════════════════════════════ */
907
+ .chart-grid {
908
+ display: grid;
909
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
910
+ gap: 12px;
911
+ margin-bottom: 12px;
912
+ }
913
+
914
+ .chart-grid > * {
915
+ min-width: 0;
916
+ }
917
+
918
+ .chart-grid-3 {
919
+ display: grid;
920
+ grid-template-columns: 1fr 1fr 1fr;
921
+ gap: 12px;
922
+ margin-bottom: 12px;
923
+ }
924
+
925
+ .chart-full {
926
+ grid-column: 1 / -1;
927
+ }
928
+
929
+ .chart-wrap {
930
+ position: relative;
931
+ height: 260px;
932
+ }
933
+
934
+ .chart-wrap-sm {
935
+ position: relative;
936
+ height: 200px;
937
+ }
938
+
939
+ .chart-wrap-lg {
940
+ position: relative;
941
+ height: 320px;
942
+ }
943
+
944
+ .chart-wrap-xl {
945
+ position: relative;
946
+ height: 380px;
947
+ }
948
+
949
+ /* ── Legend ── */
950
+ .legend-row {
951
+ display: flex;
952
+ gap: 16px;
953
+ flex-wrap: wrap;
954
+ justify-content: center;
955
+ margin-top: 12px;
956
+ }
957
+
958
+ .legend-item {
959
+ display: flex;
960
+ align-items: center;
961
+ gap: 6px;
962
+ font-size: 11.5px;
963
+ color: var(--tx2);
964
+ }
965
+
966
+ .legend-dot {
967
+ width: 8px;
968
+ height: 8px;
969
+ border-radius: 2px;
970
+ flex-shrink: 0;
971
+ }
972
+
973
+ /* ══════════════════════════════════════════════════
974
+ UPLOAD PAGE
975
+ ══════════════════════════════════════════════════ */
976
+ .upload-page-body {
977
+ display: flex !important;
978
+ flex-direction: column;
979
+ justify-content: center;
980
+ align-items: center;
981
+ min-height: calc(100vh - 56px);
982
+ /* subtract topbar */
983
+ padding-top: 20px !important;
984
+ padding-bottom: 40px !important;
985
+ }
986
+
987
+ .upload-hero {
988
+ width: 100%;
989
+ max-width: 520px;
990
+ margin: 0 auto;
991
+ text-align: center;
992
+ }
993
+
994
+ .upload-headline {
995
+ margin-bottom: 32px;
996
+ }
997
+
998
+ .upload-headline h1 {
999
+ font-size: 30px;
1000
+ font-weight: 800;
1001
+ letter-spacing: -0.8px;
1002
+ color: var(--tx1);
1003
+ margin: 0 0 10px;
1004
+ line-height: 1.2;
1005
+ }
1006
+
1007
+ .upload-headline p {
1008
+ font-size: 14px;
1009
+ color: var(--tx2);
1010
+ line-height: 1.6;
1011
+ margin: 0;
1012
+ }
1013
+
1014
+ /* Upload zone */
1015
+ .upload-zone {
1016
+ border: 1.5px dashed var(--border-focus);
1017
+ border-radius: 20px;
1018
+ background: var(--bg-card);
1019
+ transition: all var(--t-normal);
1020
+ cursor: pointer;
1021
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.02);
1022
+ }
1023
+
1024
+ .upload-zone:hover,
1025
+ .upload-zone.drag {
1026
+ border-color: var(--acc);
1027
+ background: var(--nav-hover-bg);
1028
+ box-shadow: 0 8px 32px rgba(108, 143, 255, 0.06);
1029
+ transform: translateY(-2px);
1030
+ }
1031
+
1032
+ .upload-zone input[type=file] {
1033
+ display: none;
1034
+ }
1035
+
1036
+ .upload-inner {
1037
+ padding: 56px 24px;
1038
+ display: flex;
1039
+ flex-direction: column;
1040
+ align-items: center;
1041
+ gap: 0;
1042
+ }
1043
+
1044
+ .upload-circle {
1045
+ width: 60px;
1046
+ height: 60px;
1047
+ border-radius: 50%;
1048
+ background: var(--acc-d);
1049
+ color: var(--acc);
1050
+ display: flex;
1051
+ align-items: center;
1052
+ justify-content: center;
1053
+ margin-bottom: 20px;
1054
+ }
1055
+
1056
+ .upload-title {
1057
+ font-size: 17px;
1058
+ font-weight: 700;
1059
+ color: var(--tx1);
1060
+ margin: 0 0 4px 0;
1061
+ }
1062
+
1063
+ .upload-sub {
1064
+ font-size: 13.5px;
1065
+ color: var(--tx2);
1066
+ margin: 0 0 20px 0;
1067
+ }
1068
+
1069
+ .btn-upload-trigger {
1070
+ margin-bottom: 24px;
1071
+ }
1072
+
1073
+ .upload-hint {
1074
+ font-size: 12px;
1075
+ color: var(--tx3);
1076
+ margin: 0;
1077
+ }
1078
+
1079
+ /* File preview */
1080
+ .file-preview {
1081
+ margin-top: 16px;
1082
+ background: var(--bg-card);
1083
+ border: 1px solid var(--border);
1084
+ border-radius: var(--r3);
1085
+ padding: 16px 20px;
1086
+ display: flex;
1087
+ align-items: center;
1088
+ justify-content: space-between;
1089
+ gap: 16px;
1090
+ box-shadow: var(--shadow-md);
1091
+ }
1092
+
1093
+ .file-info {
1094
+ display: flex;
1095
+ flex-direction: column;
1096
+ gap: 3px;
1097
+ }
1098
+
1099
+ .file-name {
1100
+ font-size: 14px;
1101
+ font-weight: 600;
1102
+ color: var(--tx1);
1103
+ }
1104
+
1105
+ .file-meta {
1106
+ font-size: 11px;
1107
+ color: var(--tx3);
1108
+ }
1109
+
1110
+ .file-actions {
1111
+ display: flex;
1112
+ gap: 8px;
1113
+ }
1114
+
1115
+ /* Progress */
1116
+ .progress-wrap {
1117
+ margin-top: 24px;
1118
+ }
1119
+
1120
+ .progress-header {
1121
+ display: flex;
1122
+ justify-content: space-between;
1123
+ font-size: 12px;
1124
+ color: var(--tx2);
1125
+ margin-bottom: 9px;
1126
+ }
1127
+
1128
+ .progress-track {
1129
+ height: 5px;
1130
+ border-radius: 100px;
1131
+ background: var(--bg-card2);
1132
+ overflow: hidden;
1133
+ }
1134
+
1135
+ .progress-bar {
1136
+ height: 100%;
1137
+ border-radius: 100px;
1138
+ background: linear-gradient(90deg, var(--acc), var(--pur));
1139
+ transition: width 0.3s ease;
1140
+ }
1141
+
1142
+ .progress-step {
1143
+ font-size: 11px;
1144
+ color: var(--tx3);
1145
+ margin-top: 8px;
1146
+ min-height: 16px;
1147
+ font-style: italic;
1148
+ }
1149
+
1150
+ /* ══════════════════════════════════════════════════
1151
+ DASHBOARD
1152
+ ════════════════════════════════════���═════════════ */
1153
+ .gauge-wrap {
1154
+ display: flex;
1155
+ justify-content: center;
1156
+ margin: 4px 0 0;
1157
+ }
1158
+
1159
+ .tweet-card {
1160
+ background: var(--bg-card2);
1161
+ border-radius: var(--r2);
1162
+ padding: 12px 14px;
1163
+ margin-bottom: 8px;
1164
+ border-left: 3px solid var(--border);
1165
+ transition: border-color var(--t-fast), transform var(--t-fast);
1166
+ }
1167
+
1168
+ .tweet-card:hover {
1169
+ transform: translateX(2px);
1170
+ }
1171
+
1172
+ .tweet-card.pos {
1173
+ border-left-color: var(--pos);
1174
+ }
1175
+
1176
+ .tweet-card.neg {
1177
+ border-left-color: var(--neg);
1178
+ }
1179
+
1180
+ .tweet-text {
1181
+ font-size: 12.5px;
1182
+ color: var(--tx2);
1183
+ line-height: 1.5;
1184
+ margin-bottom: 7px;
1185
+ }
1186
+
1187
+ .tweet-meta {
1188
+ font-size: 10.5px;
1189
+ color: var(--tx3);
1190
+ display: flex;
1191
+ gap: 14px;
1192
+ flex-wrap: wrap;
1193
+ }
1194
+
1195
+ .leaderboard-row {
1196
+ display: flex;
1197
+ align-items: center;
1198
+ padding: 9px 0;
1199
+ border-bottom: 1px solid var(--border-s);
1200
+ font-size: 12.5px;
1201
+ }
1202
+
1203
+ .leaderboard-row:last-child {
1204
+ border-bottom: none;
1205
+ }
1206
+
1207
+ .lb-rank {
1208
+ color: var(--tx3);
1209
+ width: 22px;
1210
+ font-size: 11px;
1211
+ font-weight: 600;
1212
+ flex-shrink: 0;
1213
+ }
1214
+
1215
+ .lb-name {
1216
+ color: var(--tx1);
1217
+ font-weight: 500;
1218
+ flex: 1;
1219
+ padding: 0 10px;
1220
+ }
1221
+
1222
+ .lb-val {
1223
+ color: var(--tx3);
1224
+ font-size: 11.5px;
1225
+ white-space: nowrap;
1226
+ }
1227
+
1228
+ .insight-list {
1229
+ display: flex;
1230
+ flex-direction: column;
1231
+ gap: 9px;
1232
+ }
1233
+
1234
+ .insight-item {
1235
+ display: flex;
1236
+ gap: 12px;
1237
+ padding: 11px 14px;
1238
+ background: var(--bg-card2);
1239
+ border-radius: var(--r2);
1240
+ border: 1px solid var(--border-s);
1241
+ align-items: flex-start;
1242
+ transition: border-color var(--t-fast);
1243
+ }
1244
+
1245
+ .insight-item:hover {
1246
+ border-color: var(--border);
1247
+ }
1248
+
1249
+ .insight-dot {
1250
+ width: 8px;
1251
+ height: 8px;
1252
+ border-radius: 50%;
1253
+ margin-top: 4px;
1254
+ flex-shrink: 0;
1255
+ }
1256
+
1257
+ .insight-text {
1258
+ font-size: 12.5px;
1259
+ color: var(--tx2);
1260
+ line-height: 1.55;
1261
+ }
1262
+
1263
+ /* ══════════════════════════════════════════════════
1264
+ TABLE & DATA PAGE OVERHAUL
1265
+ ══════════════════════════════════════════════════ */
1266
+ /* ── Custom Number Input Wrapper ── */
1267
+ .cni-wrap {
1268
+ position: relative;
1269
+ display: inline-flex;
1270
+ align-items: center;
1271
+ }
1272
+
1273
+ .cni-wrap input[type=number] {
1274
+ padding: 8px 32px 8px 12px;
1275
+ border-radius: var(--r2);
1276
+ border: 1px solid var(--border);
1277
+ background: var(--bg-input);
1278
+ color: var(--tx1);
1279
+ font-size: 13px;
1280
+ font-family: var(--font);
1281
+ font-weight: 600;
1282
+ outline: none;
1283
+ transition: all var(--t-fast);
1284
+ height: 38px;
1285
+ -webkit-appearance: none;
1286
+ -moz-appearance: textfield;
1287
+ appearance: textfield;
1288
+ }
1289
+
1290
+ .cni-wrap input[type=number]:focus {
1291
+ border-color: var(--acc);
1292
+ box-shadow: 0 0 0 3px var(--acc-d);
1293
+ background: var(--bg-card2);
1294
+ }
1295
+
1296
+ .cni-wrap input[type=number]::-webkit-inner-spin-button,
1297
+ .cni-wrap input[type=number]::-webkit-outer-spin-button {
1298
+ -webkit-appearance: none;
1299
+ appearance: none;
1300
+ margin: 0;
1301
+ }
1302
+
1303
+ .cni-arrows {
1304
+ position: absolute;
1305
+ right: 6px;
1306
+ display: flex;
1307
+ flex-direction: column;
1308
+ gap: 1px;
1309
+ opacity: 0.4;
1310
+ transition: opacity var(--t-fast);
1311
+ }
1312
+
1313
+ .cni-wrap:hover .cni-arrows,
1314
+ .cni-wrap:focus-within .cni-arrows {
1315
+ opacity: 1;
1316
+ }
1317
+
1318
+ .cni-btn {
1319
+ display: flex;
1320
+ align-items: center;
1321
+ justify-content: center;
1322
+ width: 20px;
1323
+ height: 14px;
1324
+ background: transparent;
1325
+ border: none;
1326
+ color: var(--tx3);
1327
+ cursor: pointer;
1328
+ border-radius: 4px;
1329
+ transition: all var(--t-fast);
1330
+ padding: 0;
1331
+ }
1332
+
1333
+ .cni-btn:hover {
1334
+ background: var(--nav-hover-bg);
1335
+ color: var(--tx1);
1336
+ }
1337
+
1338
+ .cni-btn:active {
1339
+ background: var(--acc-d);
1340
+ color: var(--acc);
1341
+ }
1342
+
1343
+ .cni-btn svg {
1344
+ width: 11px;
1345
+ height: 11px;
1346
+ }
1347
+
1348
+ .tl-filter-bar {
1349
+ display: flex;
1350
+ align-items: center;
1351
+ gap: 16px;
1352
+ padding: 14px 20px;
1353
+ background: var(--bg-card);
1354
+ border: 1px solid var(--border);
1355
+ border-radius: var(--r3);
1356
+ margin-bottom: 24px;
1357
+ flex-wrap: wrap; /* Already wraps, but needs children to behave correctly */
1358
+ box-shadow: var(--shadow-sm);
1359
+ }
1360
+
1361
+ .tl-filter-tabs {
1362
+ display: flex;
1363
+ align-items: center;
1364
+ gap: 8px;
1365
+ flex-wrap: wrap;
1366
+ }
1367
+
1368
+ .tl-filter-tabs .tl-tab {
1369
+ white-space: nowrap;
1370
+ }
1371
+
1372
+ .filter-group {
1373
+ display: flex;
1374
+ align-items: center;
1375
+ gap: 12px;
1376
+ }
1377
+
1378
+ .filter-label {
1379
+ font-size: 11px;
1380
+ font-weight: 800;
1381
+ color: var(--tx3);
1382
+ text-transform: uppercase;
1383
+ letter-spacing: 0.8px;
1384
+ white-space: nowrap;
1385
+ }
1386
+
1387
+ /* ── Select / Dropdown (shared) ── */
1388
+ .tl-filter-bar select,
1389
+ .tl-sort-sel,
1390
+ .page-size-sel {
1391
+ -webkit-appearance: none;
1392
+ appearance: none;
1393
+ padding: 8px 36px 8px 12px;
1394
+ border-radius: var(--r2);
1395
+ border: 1px solid var(--border);
1396
+ background-color: var(--bg-input);
1397
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2392a0b8' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
1398
+ background-repeat: no-repeat;
1399
+ background-position: right 10px center;
1400
+ background-size: 14px 14px;
1401
+ color: var(--tx1);
1402
+ font-size: 12.5px;
1403
+ font-weight: 600;
1404
+ height: 40px;
1405
+ min-width: 140px;
1406
+ transition: all var(--t-fast);
1407
+ cursor: pointer;
1408
+ }
1409
+
1410
+ .tl-filter-bar select:focus,
1411
+ .tl-sort-sel:focus,
1412
+ .page-size-sel:focus {
1413
+ border-color: var(--acc);
1414
+ box-shadow: 0 0 0 3px var(--acc-d);
1415
+ outline: none;
1416
+ background-color: var(--bg-card2);
1417
+ }
1418
+
1419
+ .filter-search-wrap {
1420
+ position: relative;
1421
+ flex: 2;
1422
+ min-width: 280px;
1423
+ }
1424
+
1425
+ .filter-search-wrap input {
1426
+ width: 100%;
1427
+ padding-left: 42px !important;
1428
+ border-radius: var(--r2);
1429
+ border: 1px solid var(--border);
1430
+ background: var(--bg-input);
1431
+ color: var(--tx1);
1432
+ font-size: 13.5px;
1433
+ height: 40px;
1434
+ transition: all var(--t-fast);
1435
+ }
1436
+
1437
+ .filter-search-wrap input:focus {
1438
+ border-color: var(--acc);
1439
+ box-shadow: 0 0 0 3px var(--acc-d);
1440
+ background: var(--bg-card2);
1441
+ }
1442
+
1443
+ .tl-search-icon {
1444
+ position: absolute;
1445
+ left: 14px;
1446
+ top: 50%;
1447
+ transform: translateY(-50%);
1448
+ width: 17px;
1449
+ height: 17px;
1450
+ color: var(--tx3);
1451
+ pointer-events: none;
1452
+ opacity: 0.6;
1453
+ }
1454
+
1455
+ .mini-inputs {
1456
+ display: flex !important;
1457
+ gap: 12px;
1458
+ }
1459
+
1460
+ .mini-inputs .cni-wrap input {
1461
+ width: 170px;
1462
+ height: 40px;
1463
+ }
1464
+
1465
+ .btn-mini {
1466
+ padding: 6px 14px;
1467
+ font-size: 11px;
1468
+ height: 36px;
1469
+ font-weight: 700;
1470
+ text-transform: uppercase;
1471
+ letter-spacing: 0.5px;
1472
+ }
1473
+
1474
+ /* Column Visibility */
1475
+ .col-visibility-wrap {
1476
+ margin-bottom: 24px;
1477
+ padding: 0 4px;
1478
+ }
1479
+
1480
+ .col-toggle-label {
1481
+ font-size: 10.5px;
1482
+ color: var(--tx3);
1483
+ margin-bottom: 12px;
1484
+ font-weight: 800;
1485
+ text-transform: uppercase;
1486
+ letter-spacing: 0.8px;
1487
+ }
1488
+
1489
+ .col-toggle {
1490
+ display: flex;
1491
+ gap: 8px;
1492
+ flex-wrap: wrap;
1493
+ }
1494
+
1495
+ .col-pill {
1496
+ padding: 5px 14px;
1497
+ border-radius: 100px;
1498
+ border: 1px solid var(--border);
1499
+ background: var(--bg-card2);
1500
+ font-size: 11px;
1501
+ font-weight: 600;
1502
+ color: var(--tx3);
1503
+ cursor: pointer;
1504
+ transition: all var(--t-fast);
1505
+ user-select: none;
1506
+ }
1507
+
1508
+ .col-pill:hover {
1509
+ border-color: var(--border-focus);
1510
+ color: var(--tx2);
1511
+ }
1512
+
1513
+ .col-pill.on {
1514
+ background: var(--acc-d);
1515
+ border-color: var(--acc-d);
1516
+ color: var(--acc);
1517
+ }
1518
+
1519
+ /* Table Card Area */
1520
+ .table-card {
1521
+ background: var(--bg-card);
1522
+ border: 1px solid var(--border);
1523
+ border-radius: var(--r3);
1524
+ overflow: hidden;
1525
+ box-shadow: var(--shadow-sm);
1526
+ margin-bottom: 32px;
1527
+ }
1528
+
1529
+ .table-wrap {
1530
+ overflow-x: auto;
1531
+ }
1532
+
1533
+ .data-table {
1534
+ width: 100%;
1535
+ border-collapse: collapse;
1536
+ }
1537
+
1538
+ .data-table thead tr {
1539
+ background: var(--bg-card2);
1540
+ }
1541
+
1542
+ .data-table th {
1543
+ padding: 14px 16px;
1544
+ font-size: 11px;
1545
+ font-weight: 700;
1546
+ text-transform: uppercase;
1547
+ letter-spacing: 0.6px;
1548
+ color: var(--tx3);
1549
+ text-align: left;
1550
+ white-space: nowrap;
1551
+ user-select: none;
1552
+ border-bottom: 1px solid var(--border);
1553
+ cursor: pointer;
1554
+ transition: all var(--t-fast);
1555
+ }
1556
+
1557
+ .data-table th:hover {
1558
+ color: var(--tx1);
1559
+ background: rgba(108, 143, 255, 0.05);
1560
+ }
1561
+
1562
+ .data-table th.active-sort {
1563
+ color: var(--acc);
1564
+ background: var(--acc-d);
1565
+ }
1566
+
1567
+ .data-table th.sort-asc::after {
1568
+ content: ' ↑';
1569
+ color: var(--acc);
1570
+ }
1571
+
1572
+ .data-table th.sort-desc::after {
1573
+ content: ' ↓';
1574
+ color: var(--acc);
1575
+ }
1576
+
1577
+ .data-table td {
1578
+ padding: 12px 16px;
1579
+ font-size: 12.5px;
1580
+ color: var(--tx2);
1581
+ border-bottom: 1px solid var(--border-s);
1582
+ vertical-align: middle;
1583
+ }
1584
+
1585
+ .data-table tbody tr:hover {
1586
+ background: rgba(108, 143, 255, 0.02);
1587
+ }
1588
+
1589
+ .data-table tbody tr:last-child td {
1590
+ border-bottom: none;
1591
+ }
1592
+
1593
+
1594
+
1595
+ .td-no {
1596
+ color: var(--tx3);
1597
+ font-weight: 600;
1598
+ width: 40px;
1599
+ font-variant-numeric: tabular-nums;
1600
+ }
1601
+
1602
+ .td-trunc {
1603
+ max-width: 240px;
1604
+ overflow: hidden;
1605
+ text-overflow: ellipsis;
1606
+ white-space: nowrap;
1607
+ }
1608
+
1609
+ .td-trunc-sm {
1610
+ max-width: 130px;
1611
+ overflow: hidden;
1612
+ text-overflow: ellipsis;
1613
+ white-space: nowrap;
1614
+ }
1615
+
1616
+ /* Conf bar */
1617
+ .conf-row {
1618
+ display: flex;
1619
+ align-items: center;
1620
+ gap: 7px;
1621
+ }
1622
+
1623
+ .conf-num {
1624
+ font-size: 11.5px;
1625
+ min-width: 40px;
1626
+ font-variant-numeric: tabular-nums;
1627
+ }
1628
+
1629
+ .conf-track {
1630
+ width: 56px;
1631
+ height: 4px;
1632
+ border-radius: 100px;
1633
+ background: var(--bg-card2);
1634
+ overflow: hidden;
1635
+ }
1636
+
1637
+ .conf-fill {
1638
+ height: 100%;
1639
+ border-radius: 100px;
1640
+ transition: width 0.4s ease;
1641
+ }
1642
+
1643
+ .conf-pos {
1644
+ background: var(--pos);
1645
+ }
1646
+
1647
+ .conf-neg {
1648
+ background: var(--neg);
1649
+ }
1650
+
1651
+ .conf-neu {
1652
+ background: var(--neu);
1653
+ }
1654
+
1655
+ /* Pagination */
1656
+ .pagination,
1657
+ .tl-pagination {
1658
+ display: flex;
1659
+ justify-content: center;
1660
+ align-items: center;
1661
+ gap: 8px; /* Slightly more gap */
1662
+ margin-top: 24px;
1663
+ flex-wrap: wrap;
1664
+ }
1665
+
1666
+ .tl-pagination-stack {
1667
+ display: flex;
1668
+ flex-direction: column;
1669
+ align-items: center;
1670
+ margin-top: 40px;
1671
+ gap: 20px;
1672
+ }
1673
+
1674
+ .pg-btn {
1675
+ display: inline-flex;
1676
+ justify-content: center;
1677
+ align-items: center;
1678
+ min-width: 36px;
1679
+ height: 36px;
1680
+ padding: 0 8px;
1681
+ border-radius: 10px; /* More modern rounding */
1682
+ border: 1px solid transparent;
1683
+ background: transparent;
1684
+ color: var(--tx2);
1685
+ font-size: 14px;
1686
+ font-family: var(--font);
1687
+ font-weight: 600;
1688
+ cursor: pointer;
1689
+ transition: all var(--t-fast);
1690
+ user-select: none;
1691
+ }
1692
+
1693
+ .pg-btn:hover:not(:disabled):not(.dots) {
1694
+ background: var(--bg-card);
1695
+ border-color: var(--border);
1696
+ color: var(--tx1);
1697
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
1698
+ }
1699
+
1700
+ .pg-btn.active {
1701
+ background: var(--acc);
1702
+ border-color: var(--acc);
1703
+ color: #fff;
1704
+ box-shadow: 0 8px 20px rgba(108, 143, 255, 0.35); /* More premium shadow */
1705
+ transform: translateY(-1px);
1706
+ }
1707
+
1708
+ .pg-btn:disabled,
1709
+ .pg-btn.dots {
1710
+ opacity: 0.3;
1711
+ cursor: default;
1712
+ background: transparent;
1713
+ border-color: transparent;
1714
+ }
1715
+
1716
+ .tl-pagination-info {
1717
+ font-size: 13px;
1718
+ color: var(--tx3);
1719
+ font-weight: 500;
1720
+ letter-spacing: -0.01em;
1721
+ }
1722
+
1723
+ .tl-page-size-row {
1724
+ display: flex;
1725
+ align-items: center;
1726
+ gap: 12px;
1727
+ margin-top: 4px;
1728
+ }
1729
+
1730
+ .tl-page-size-label {
1731
+ font-size: 12.5px;
1732
+ color: var(--tx3);
1733
+ font-weight: 500;
1734
+ }
1735
+
1736
+ .tl-page-size-sel {
1737
+ width: 80px;
1738
+ }
1739
+
1740
+ .table-info,
1741
+ .tl-info {
1742
+ text-align: center;
1743
+ font-size: 12px;
1744
+ color: var(--tx3);
1745
+ margin-top: 14px;
1746
+ }
1747
+
1748
+ /* ══════════════════════════════════════════════════
1749
+ EMPTY STATES
1750
+ ══════════════════════════════════════════════════ */
1751
+ .empty-state {
1752
+ display: flex;
1753
+ flex-direction: column;
1754
+ align-items: center;
1755
+ justify-content: center;
1756
+ padding: 32px 20px;
1757
+ text-align: center;
1758
+ border-radius: var(--r2);
1759
+ background: var(--bg-card2);
1760
+ border: 1px dashed var(--border);
1761
+ min-height: 120px;
1762
+ margin: 10px 0;
1763
+ }
1764
+
1765
+ .empty-state svg {
1766
+ width: 32px;
1767
+ height: 32px;
1768
+ color: var(--tx3);
1769
+ margin-bottom: 12px;
1770
+ opacity: 0.6;
1771
+ }
1772
+
1773
+ .empty-state-title {
1774
+ font-size: 14px;
1775
+ font-weight: 600;
1776
+ color: var(--tx2);
1777
+ margin-bottom: 4px;
1778
+ }
1779
+
1780
+ .empty-state-desc {
1781
+ font-size: 12px;
1782
+ color: var(--tx3);
1783
+ }
1784
+
1785
+ /* ══════════════════════════════════════════════════
1786
+ CLEANING LAB
1787
+ ══════════════════════════════════════════════════ */
1788
+ .pipeline-step {
1789
+ display: flex;
1790
+ align-items: flex-start;
1791
+ gap: 14px;
1792
+ padding: 14px 0;
1793
+ border-bottom: 1px solid var(--border-s);
1794
+ }
1795
+
1796
+ .pipeline-step:last-child {
1797
+ border-bottom: none;
1798
+ }
1799
+
1800
+ .step-num {
1801
+ width: 28px;
1802
+ height: 28px;
1803
+ border-radius: 50%;
1804
+ background: var(--acc-d);
1805
+ color: var(--acc);
1806
+ font-size: 11px;
1807
+ font-weight: 700;
1808
+ display: flex;
1809
+ align-items: center;
1810
+ justify-content: center;
1811
+ flex-shrink: 0;
1812
+ border: 1px solid var(--acc-d2);
1813
+ }
1814
+
1815
+ .step-info {
1816
+ flex: 1;
1817
+ }
1818
+
1819
+ .step-label {
1820
+ font-size: 13px;
1821
+ font-weight: 600;
1822
+ color: var(--tx1);
1823
+ margin-bottom: 2px;
1824
+ }
1825
+
1826
+ .step-desc {
1827
+ font-size: 11.5px;
1828
+ color: var(--tx3);
1829
+ }
1830
+
1831
+ .step-toggle {
1832
+ width: 36px;
1833
+ height: 20px;
1834
+ border-radius: 100px;
1835
+ background: var(--bg-card2);
1836
+ border: 1px solid var(--border);
1837
+ position: relative;
1838
+ cursor: pointer;
1839
+ transition: background var(--t-normal), border-color var(--t-normal);
1840
+ flex-shrink: 0;
1841
+ }
1842
+
1843
+ .step-toggle.on {
1844
+ background: var(--acc);
1845
+ border-color: var(--acc);
1846
+ }
1847
+
1848
+ .step-toggle::after {
1849
+ content: '';
1850
+ position: absolute;
1851
+ width: 14px;
1852
+ height: 14px;
1853
+ border-radius: 50%;
1854
+ background: white;
1855
+ top: 2px;
1856
+ left: 2px;
1857
+ transition: transform var(--t-normal);
1858
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
1859
+ }
1860
+
1861
+ .step-toggle.on::after {
1862
+ transform: translateX(16px);
1863
+ }
1864
+
1865
+ .demo-area {
1866
+ margin-bottom: 16px;
1867
+ }
1868
+
1869
+ .demo-label {
1870
+ font-size: 10.5px;
1871
+ text-transform: uppercase;
1872
+ letter-spacing: 0.55px;
1873
+ font-weight: 600;
1874
+ color: var(--tx3);
1875
+ margin-bottom: 8px;
1876
+ }
1877
+
1878
+ .demo-textarea {
1879
+ width: 100%;
1880
+ background: var(--bg-input);
1881
+ border: 1px solid var(--border);
1882
+ border-radius: var(--r2);
1883
+ color: var(--tx1);
1884
+ font-family: var(--font);
1885
+ font-size: 13px;
1886
+ padding: 12px 14px;
1887
+ outline: none;
1888
+ resize: vertical;
1889
+ min-height: 80px;
1890
+ transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-slow);
1891
+ }
1892
+
1893
+ .demo-textarea:focus {
1894
+ border-color: var(--acc);
1895
+ box-shadow: 0 0 0 3px var(--acc-d);
1896
+ }
1897
+
1898
+ .step-pipeline {
1899
+ display: flex;
1900
+ flex-direction: column;
1901
+ gap: 5px;
1902
+ }
1903
+
1904
+ .step-line {
1905
+ display: flex;
1906
+ gap: 10px;
1907
+ align-items: flex-start;
1908
+ font-size: 12px;
1909
+ padding: 8px 12px;
1910
+ border-radius: var(--r1);
1911
+ background: var(--bg-card2);
1912
+ border: 1px solid var(--border-s);
1913
+ transition: background var(--t-fast);
1914
+ }
1915
+
1916
+ .step-line-num {
1917
+ color: var(--tx3);
1918
+ width: 18px;
1919
+ flex-shrink: 0;
1920
+ font-variant-numeric: tabular-nums;
1921
+ }
1922
+
1923
+ .step-line-name {
1924
+ color: var(--acc);
1925
+ width: 150px;
1926
+ flex-shrink: 0;
1927
+ font-weight: 500;
1928
+ }
1929
+
1930
+ .step-line-text {
1931
+ color: var(--tx2);
1932
+ flex: 1;
1933
+ word-break: break-word;
1934
+ }
1935
+
1936
+ .step-line.changed {
1937
+ background: var(--acc-d);
1938
+ border-color: var(--acc-d2);
1939
+ }
1940
+
1941
+ .step-diff {
1942
+ color: var(--tx3);
1943
+ font-size: 10.5px;
1944
+ margin-left: auto;
1945
+ white-space: nowrap;
1946
+ padding-left: 10px;
1947
+ align-self: flex-start; /* Keep at top if row gets tall */
1948
+ }
1949
+
1950
+ .stat-pill {
1951
+ display: inline-flex;
1952
+ align-items: center;
1953
+ gap: 6px;
1954
+ padding: 6px 14px;
1955
+ border-radius: 100px;
1956
+ background: var(--bg-card2);
1957
+ border: 1px solid var(--border);
1958
+ font-size: 12px;
1959
+ color: var(--tx2);
1960
+ }
1961
+
1962
+ .stat-pill strong {
1963
+ color: var(--tx1);
1964
+ font-weight: 700;
1965
+ }
1966
+
1967
+ /* ══════════════════════════════════════════════════
1968
+ MISC
1969
+ ══════════════════════════════════════════════════ */
1970
+ .empty-state {
1971
+ padding: 64px 20px;
1972
+ text-align: center;
1973
+ color: var(--tx3);
1974
+ }
1975
+
1976
+ .empty-title {
1977
+ font-size: 15px;
1978
+ font-weight: 600;
1979
+ margin-bottom: 6px;
1980
+ color: var(--tx2);
1981
+ }
1982
+
1983
+ .empty-desc {
1984
+ font-size: 13px;
1985
+ }
1986
+
1987
+ /* ── Animations ── */
1988
+ @keyframes fadeUp {
1989
+ from {
1990
+ opacity: 0;
1991
+ transform: translateY(12px);
1992
+ }
1993
+
1994
+ to {
1995
+ opacity: 1;
1996
+ transform: translateY(0);
1997
+ }
1998
+ }
1999
+
2000
+ .kpi {
2001
+ animation: fadeUp 0.35s ease both;
2002
+ }
2003
+
2004
+ .kpi:nth-child(1) {
2005
+ animation-delay: .04s
2006
+ }
2007
+
2008
+ .kpi:nth-child(2) {
2009
+ animation-delay: .07s
2010
+ }
2011
+
2012
+ .kpi:nth-child(3) {
2013
+ animation-delay: .10s
2014
+ }
2015
+
2016
+ .kpi:nth-child(4) {
2017
+ animation-delay: .13s
2018
+ }
2019
+
2020
+ .kpi:nth-child(5) {
2021
+ animation-delay: .16s
2022
+ }
2023
+
2024
+ .kpi:nth-child(6) {
2025
+ animation-delay: .19s
2026
+ }
2027
+
2028
+ .kpi:nth-child(7) {
2029
+ animation-delay: .22s
2030
+ }
2031
+
2032
+ .kpi:nth-child(8) {
2033
+ animation-delay: .25s
2034
+ }
2035
+
2036
+ @keyframes fadeIn {
2037
+ from {
2038
+ opacity: 0;
2039
+ }
2040
+
2041
+ to {
2042
+ opacity: 1;
2043
+ }
2044
+ }
2045
+
2046
+ .card {
2047
+ animation: fadeIn 0.3s ease both;
2048
+ }
2049
+
2050
+ /* ── Transition for theme switch ── */
2051
+ body,
2052
+ .card,
2053
+ .kpi,
2054
+ #sidebar,
2055
+ .topbar,
2056
+ .filter-bar select,
2057
+ .filter-bar input,
2058
+ .data-table th,
2059
+ .data-table td,
2060
+ .pg-btn,
2061
+ .badge,
2062
+ .btn,
2063
+ .nav-item,
2064
+ .tweet-card,
2065
+ .leaderboard-row,
2066
+ .insight-item,
2067
+ .step-line,
2068
+ .upload-zone,
2069
+ .theme-toggle {
2070
+ transition-property: background, color, border-color, box-shadow;
2071
+ transition-duration: var(--t-slow);
2072
+ transition-timing-function: ease;
2073
+ }
2074
+
2075
+ /* ══════════════════════════════════════════════════
2076
+ RESPONSIVE
2077
+ ══════════════════════════════════════════════════ */
2078
+ @media (max-width: 1400px) {
2079
+ .kpi-grid {
2080
+ grid-template-columns: repeat(4, 1fr);
2081
+ }
2082
+ }
2083
+
2084
+ @media (max-width: 1100px) {
2085
+ .chart-grid-3 {
2086
+ grid-template-columns: 1fr 1fr;
2087
+ }
2088
+ }
2089
+
2090
+ @media (max-width: 860px) {
2091
+ #sidebar {
2092
+ transform: translateX(-100%);
2093
+ box-shadow: none;
2094
+ transition: transform var(--t-slow) cubic-bezier(0.16, 1, 0.3, 1);
2095
+ }
2096
+
2097
+ body.sidebar-mobile-open #sidebar {
2098
+ transform: translateX(0);
2099
+ box-shadow: 20px 0 60px rgba(0, 0, 0, 0.3);
2100
+ }
2101
+
2102
+ .main {
2103
+ margin-left: 0;
2104
+ }
2105
+
2106
+ /* Desktop toggle button hide on mobile */
2107
+ .sidebar-toggle-btn {
2108
+ display: none !important;
2109
+ }
2110
+
2111
+ .topbar {
2112
+ padding: 0 16px;
2113
+ gap: 8px;
2114
+ }
2115
+
2116
+ .mobile-menu-btn {
2117
+ display: flex;
2118
+ align-items: center;
2119
+ justify-content: center;
2120
+ background: transparent;
2121
+ border: none;
2122
+ color: var(--tx2);
2123
+ padding: 8px;
2124
+ cursor: pointer;
2125
+ border-radius: var(--r1);
2126
+ margin-right: 4px;
2127
+ }
2128
+
2129
+ .mobile-menu-btn:hover {
2130
+ background: var(--nav-hover-bg);
2131
+ color: var(--tx1);
2132
+ }
2133
+
2134
+ .chart-grid,
2135
+ .chart-grid-3 {
2136
+ grid-template-columns: minmax(0, 1fr);
2137
+ gap: 16px;
2138
+ width: 100%;
2139
+ }
2140
+
2141
+ .kpi-grid {
2142
+ grid-template-columns: repeat(2, 1fr);
2143
+ gap: 10px;
2144
+ }
2145
+
2146
+ .page-body {
2147
+ padding: 16px 12px 32px;
2148
+ }
2149
+
2150
+ .card-body {
2151
+ padding: 16px;
2152
+ }
2153
+
2154
+
2155
+ .expand-content {
2156
+ grid-template-columns: 1fr;
2157
+ }
2158
+
2159
+ .filter-bar, .tl-filter-bar {
2160
+ gap: 8px;
2161
+ flex-wrap: wrap; /* allow wrapping on small screens */
2162
+ }
2163
+
2164
+ /* Specific fix for Tweet List filters overflow */
2165
+ .tl-filter-bar {
2166
+ padding: 12px 14px;
2167
+ gap: 12px;
2168
+ }
2169
+
2170
+ .tl-filter-tabs {
2171
+ width: 100%; /* Force tabs to own row if needed, or wrap inside */
2172
+ justify-content: flex-start;
2173
+ }
2174
+
2175
+ .tl-search-wrap {
2176
+ width: 100%;
2177
+ min-width: 100%;
2178
+ flex: none;
2179
+ margin-bottom: 4px;
2180
+ }
2181
+
2182
+ .tl-sort-wrap {
2183
+ width: 100%;
2184
+ flex: none;
2185
+ }
2186
+
2187
+ .tl-sort-sel {
2188
+ width: 100%;
2189
+ }
2190
+
2191
+ .data-page-filters {
2192
+ display: flex;
2193
+ flex-direction: column;
2194
+ align-items: stretch;
2195
+ gap: 12px;
2196
+ }
2197
+
2198
+ .data-page-filters .filter-group {
2199
+ width: 100%;
2200
+ max-width: none;
2201
+ justify-content: space-between;
2202
+ }
2203
+
2204
+ .data-page-filters .filter-group select {
2205
+ flex: 1; /* Make select box take remaining space neatly */
2206
+ max-width: 65%;
2207
+ }
2208
+
2209
+ .data-page-filters .filter-search-wrap,
2210
+ .data-page-filters .mini-inputs {
2211
+ width: 100%;
2212
+ min-width: 100%;
2213
+ max-width: none;
2214
+ }
2215
+
2216
+ .mini-inputs {
2217
+ display: grid !important;
2218
+ grid-template-columns: 1fr 1fr;
2219
+ gap: 8px;
2220
+ width: 100%;
2221
+ }
2222
+
2223
+ .mini-inputs .cni-wrap {
2224
+ width: 100%;
2225
+ }
2226
+
2227
+ .mini-inputs .cni-wrap input {
2228
+ width: 100% !important; /* Force override for mobile */
2229
+ }
2230
+
2231
+ /* Reset button full width */
2232
+ #btnReset {
2233
+ display: flex;
2234
+ justify-content: center;
2235
+ align-items: center;
2236
+ width: 100%;
2237
+ margin-top: 4px;
2238
+ height: 40px;
2239
+ }
2240
+
2241
+ .table-card {
2242
+ border-radius: var(--r2);
2243
+ overflow: hidden;
2244
+ }
2245
+
2246
+ /* Table Horizontal Scrolling Support */
2247
+ .table-wrap, .analytics-table-wrap {
2248
+ overflow-x: auto;
2249
+ -webkit-overflow-scrolling: touch;
2250
+ width: 100%;
2251
+ margin-bottom: 0;
2252
+ }
2253
+
2254
+ .data-table th, .data-table td,
2255
+ .analytics-table th, .analytics-table td {
2256
+ white-space: nowrap; /* Prevent ugly wrapping inside tables */
2257
+ }
2258
+
2259
+ .topbar-title {
2260
+ font-size: 13px;
2261
+ }
2262
+
2263
+ /* ── Cleaning Lab Specific Mobile Overrides ── */
2264
+ .step-line {
2265
+ flex-wrap: wrap; /* allow text and status to wrap */
2266
+ gap: 6px;
2267
+ padding: 10px 12px;
2268
+ }
2269
+
2270
+ .step-line-name {
2271
+ width: 100%; /* Force step name to take its own line */
2272
+ font-size: 13px;
2273
+ }
2274
+
2275
+ .step-line-text {
2276
+ width: 100%;
2277
+ margin-left: 0;
2278
+ }
2279
+
2280
+ .step-diff {
2281
+ margin-left: 0; /* Keep diff next to text or wrapped */
2282
+ padding-left: 0;
2283
+ }
2284
+
2285
+ .topbar-sub {
2286
+ display: none;
2287
+ }
2288
+
2289
+ /* ── Upload Page Mobile Refinements ── */
2290
+ .upload-headline {
2291
+ margin-bottom: 24px;
2292
+ text-align: center;
2293
+ }
2294
+
2295
+ .upload-headline h1 {
2296
+ font-size: 24px;
2297
+ margin-bottom: 8px;
2298
+ }
2299
+
2300
+ .upload-headline p {
2301
+ font-size: 13px;
2302
+ line-height: 1.6;
2303
+ padding: 0 4px;
2304
+ }
2305
+
2306
+ .upload-inner {
2307
+ padding: 32px 16px;
2308
+ }
2309
+
2310
+ .upload-title {
2311
+ font-size: 15px;
2312
+ }
2313
+
2314
+ .upload-sub {
2315
+ font-size: 12.5px;
2316
+ margin-bottom: 16px;
2317
+ }
2318
+
2319
+ .file-preview {
2320
+ flex-direction: column;
2321
+ padding: 20px;
2322
+ gap: 20px;
2323
+ align-items: stretch;
2324
+ text-align: center;
2325
+ }
2326
+
2327
+ .file-info {
2328
+ align-items: center;
2329
+ gap: 5px;
2330
+ }
2331
+
2332
+ .file-name {
2333
+ font-size: 13.5px;
2334
+ }
2335
+
2336
+ .file-actions {
2337
+ flex-direction: column;
2338
+ gap: 10px;
2339
+ width: 100%;
2340
+ }
2341
+
2342
+ .file-actions .btn {
2343
+ width: 100%;
2344
+ justify-content: center;
2345
+ }
2346
+
2347
+ .progress-wrap {
2348
+ margin-top: 32px;
2349
+ }
2350
+
2351
+ /* ── Dashboard Mobile Refinements ── */
2352
+ .sec-head {
2353
+ flex-direction: column;
2354
+ align-items: flex-start;
2355
+ gap: 12px;
2356
+ }
2357
+
2358
+ .sec-head .actions {
2359
+ width: 100%;
2360
+ }
2361
+
2362
+ .sec-head .btn {
2363
+ width: 100%;
2364
+ justify-content: center;
2365
+ }
2366
+
2367
+ .tweet-card {
2368
+ padding: 10px 12px;
2369
+ }
2370
+
2371
+ .tweet-text {
2372
+ font-size: 12px;
2373
+ margin-bottom: 5px;
2374
+ }
2375
+
2376
+ .insight-item {
2377
+ padding: 10px 12px;
2378
+ gap: 10px;
2379
+ }
2380
+
2381
+ .insight-text {
2382
+ font-size: 12px;
2383
+ }
2384
+
2385
+ .gauge-wrap {
2386
+ margin-bottom: 0;
2387
+ }
2388
+
2389
+ .leaderboard-row {
2390
+ font-size: 11.5px;
2391
+ padding: 8px 0;
2392
+ }
2393
+
2394
+ .lb-name {
2395
+ padding: 0 6px;
2396
+ }
2397
+
2398
+ /* ── Analytics Mobile Refinements ── */
2399
+ .analytics-table {
2400
+ font-size: 11.5px;
2401
+ }
2402
+
2403
+ .analytics-table th,
2404
+ .analytics-table td {
2405
+ padding: 8px 10px;
2406
+ }
2407
+
2408
+ .at-hashtag {
2409
+ padding: 1px 6px;
2410
+ font-size: 10.5px;
2411
+ }
2412
+
2413
+ .word-tag-cloud {
2414
+ min-height: auto;
2415
+ gap: 4px;
2416
+ padding: 4px 0;
2417
+ }
2418
+
2419
+ .card-badge {
2420
+ font-size: 9px;
2421
+ padding: 1px 6px;
2422
+ }
2423
+
2424
+ .chart-wrap-sm {
2425
+ height: 180px;
2426
+ }
2427
+ }
2428
+
2429
+ @media (max-width: 480px) {
2430
+ .kpi-grid {
2431
+ grid-template-columns: repeat(2, 1fr);
2432
+ gap: 8px;
2433
+ }
2434
+
2435
+ .kpi {
2436
+ padding: 14px 12px 12px;
2437
+ }
2438
+
2439
+ .kpi-label {
2440
+ font-size: 9.5px;
2441
+ margin-bottom: 6px;
2442
+ }
2443
+
2444
+ .kpi-value {
2445
+ font-size: 20px;
2446
+ }
2447
+
2448
+ .hero-title {
2449
+ font-size: 28px;
2450
+ }
2451
+
2452
+ .btn {
2453
+ padding: 8px 14px;
2454
+ font-size: 12.5px;
2455
+ }
2456
+
2457
+ /* Fix for Analytics Table Long Topics */
2458
+ .at-topic-text {
2459
+ max-width: 130px; /* Prevent text from crashing into columns */
2460
+ }
2461
+
2462
+ .tl-page-size-row {
2463
+ width: 100%;
2464
+ justify-content: center;
2465
+ margin-top: 12px;
2466
+ }
2467
+ .tl-pagination-stack {
2468
+ flex-direction: column;
2469
+ align-items: center;
2470
+ gap: 12px;
2471
+ }
2472
+ }
2473
+
2474
+ /* Hide mobile controls on desktop */
2475
+ @media (min-width: 861px) {
2476
+ .mobile-menu-btn {
2477
+ display: none !important;
2478
+ }
2479
+ }
2480
+
2481
+ /* ══════════════════════════════════════════════════
2482
+ CUSTOM SELECT / DROPDOWN COMPONENT
2483
+ ══════════════════════════════════════════════════ */
2484
+
2485
+ /* Wrapper replaces the native <select> in DOM flow */
2486
+ .csd-wrap {
2487
+ position: relative;
2488
+ display: inline-block;
2489
+ vertical-align: middle;
2490
+ }
2491
+
2492
+ /* The visible trigger button */
2493
+ .csd-trigger {
2494
+ display: inline-flex;
2495
+ align-items: center;
2496
+ gap: 8px;
2497
+ padding: 0 12px;
2498
+ height: 34px;
2499
+ border-radius: var(--r2);
2500
+ border: 1px solid var(--border);
2501
+ background: var(--bg-input);
2502
+ color: var(--tx1);
2503
+ font-size: 12.5px;
2504
+ font-family: var(--font);
2505
+ font-weight: 500;
2506
+ cursor: pointer;
2507
+ user-select: none;
2508
+ white-space: nowrap;
2509
+ transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast), color var(--t-slow);
2510
+ outline: none;
2511
+ min-width: 130px;
2512
+ }
2513
+
2514
+ .csd-trigger:hover {
2515
+ border-color: var(--border-focus);
2516
+ background: var(--bg-card2);
2517
+ }
2518
+
2519
+ .csd-trigger.open {
2520
+ border-color: var(--acc);
2521
+ background: var(--bg-card2);
2522
+ box-shadow: 0 0 0 3px var(--acc-d);
2523
+ }
2524
+
2525
+ /* Label text inside trigger */
2526
+ .csd-label {
2527
+ flex: 1;
2528
+ overflow: hidden;
2529
+ text-overflow: ellipsis;
2530
+ text-align: left;
2531
+ }
2532
+
2533
+ /* Chevron SVG inside trigger */
2534
+ .csd-chevron {
2535
+ width: 14px;
2536
+ height: 14px;
2537
+ flex-shrink: 0;
2538
+ color: var(--tx3);
2539
+ transition: transform 220ms cubic-bezier(.4, 0, .2, 1), color var(--t-fast);
2540
+ }
2541
+
2542
+ .csd-trigger.open .csd-chevron {
2543
+ transform: rotate(180deg);
2544
+ color: var(--acc);
2545
+ }
2546
+
2547
+ /* ── Floating panel ── */
2548
+ .csd-panel {
2549
+ position: absolute;
2550
+ top: calc(100% + 6px);
2551
+ left: 0;
2552
+ min-width: 100%;
2553
+ background: var(--bg-card);
2554
+ border: 1px solid var(--border);
2555
+ border-radius: var(--r3);
2556
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.04);
2557
+ z-index: 5000;
2558
+ overflow: hidden;
2559
+ /* Animation */
2560
+ transform-origin: top center;
2561
+ animation: csdDrop 180ms cubic-bezier(.16, 1, .3, 1) both;
2562
+ }
2563
+
2564
+ @media (max-width: 768px) {
2565
+ .csd-panel {
2566
+ left: auto;
2567
+ right: 0;
2568
+ transform-origin: top right;
2569
+ }
2570
+ }
2571
+
2572
+ [data-theme="light"] .csd-panel {
2573
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.04);
2574
+ }
2575
+
2576
+ @keyframes csdDrop {
2577
+ from {
2578
+ opacity: 0;
2579
+ transform: scaleY(0.88) translateY(-6px);
2580
+ }
2581
+
2582
+ to {
2583
+ opacity: 1;
2584
+ transform: scaleY(1) translateY(0);
2585
+ }
2586
+ }
2587
+
2588
+ /* Panel header (search box) */
2589
+ .csd-search-wrap {
2590
+ padding: 10px 10px 6px;
2591
+ border-bottom: 1px solid var(--border-s);
2592
+ }
2593
+
2594
+ .csd-search {
2595
+ width: 100%;
2596
+ padding: 6px 10px;
2597
+ background: var(--bg-card2);
2598
+ border: 1px solid var(--border);
2599
+ border-radius: var(--r1);
2600
+ color: var(--tx1);
2601
+ font-size: 12px;
2602
+ font-family: var(--font);
2603
+ outline: none;
2604
+ transition: border-color var(--t-fast);
2605
+ }
2606
+
2607
+ .csd-search:focus {
2608
+ border-color: var(--acc);
2609
+ }
2610
+
2611
+ .csd-search::placeholder {
2612
+ color: var(--tx3);
2613
+ }
2614
+
2615
+ /* Options list */
2616
+ .csd-list {
2617
+ max-height: 220px;
2618
+ overflow-y: auto;
2619
+ padding: 6px;
2620
+ }
2621
+
2622
+ /* Custom scrollbar inside panel */
2623
+ .csd-list::-webkit-scrollbar {
2624
+ width: 4px;
2625
+ }
2626
+
2627
+ .csd-list::-webkit-scrollbar-track {
2628
+ background: transparent;
2629
+ }
2630
+
2631
+ .csd-list::-webkit-scrollbar-thumb {
2632
+ background: var(--border);
2633
+ border-radius: 8px;
2634
+ }
2635
+
2636
+ /* Single option row */
2637
+ .csd-option {
2638
+ display: flex;
2639
+ align-items: center;
2640
+ gap: 10px;
2641
+ padding: 8px 10px;
2642
+ border-radius: var(--r1);
2643
+ font-size: 12.5px;
2644
+ font-weight: 500;
2645
+ color: var(--tx2);
2646
+ cursor: pointer;
2647
+ transition: background var(--t-fast), color var(--t-fast);
2648
+ user-select: none;
2649
+ white-space: nowrap;
2650
+ }
2651
+
2652
+ .csd-option:hover {
2653
+ background: var(--nav-hover-bg);
2654
+ color: var(--tx1);
2655
+ }
2656
+
2657
+ .csd-option.selected {
2658
+ background: var(--acc-d);
2659
+ color: var(--acc);
2660
+ font-weight: 600;
2661
+ }
2662
+
2663
+ .csd-option.focused {
2664
+ background: var(--nav-hover-bg);
2665
+ color: var(--tx1);
2666
+ }
2667
+
2668
+ /* Check icon on selected */
2669
+ .csd-check {
2670
+ width: 14px;
2671
+ height: 14px;
2672
+ flex-shrink: 0;
2673
+ opacity: 0;
2674
+ color: var(--acc);
2675
+ margin-left: auto;
2676
+ }
2677
+
2678
+ .csd-option.selected .csd-check {
2679
+ opacity: 1;
2680
+ }
2681
+
2682
+ /* Sentiment dot indicators */
2683
+ .csd-dot {
2684
+ width: 7px;
2685
+ height: 7px;
2686
+ border-radius: 50%;
2687
+ flex-shrink: 0;
2688
+ }
2689
+
2690
+ /* Empty state inside list */
2691
+ .csd-empty {
2692
+ padding: 14px 10px;
2693
+ text-align: center;
2694
+ font-size: 12px;
2695
+ color: var(--tx3);
2696
+ font-style: italic;
2697
+ }
2698
+
2699
+ /* ── Page-size variant (compact, no search) ── */
2700
+ .csd-wrap.csd-compact .csd-trigger {
2701
+ min-width: 80px;
2702
+ }
2703
+
2704
+ .csd-wrap.csd-compact .csd-panel {
2705
+ min-width: 100px;
2706
+ }
2707
+
2708
+ .csd-wrap.csd-compact .csd-search-wrap {
2709
+ display: none;
2710
+ }
2711
+
2712
+ /* ══════════════════════════════════════════════════
2713
+ TOAST NOTIFICATIONS
2714
+ ══════════════════════════════════════════════════ */
2715
+ .toast-container {
2716
+ position: fixed;
2717
+ bottom: 40px;
2718
+ left: 50%;
2719
+ transform: translateX(-50%);
2720
+ display: flex;
2721
+ flex-direction: column;
2722
+ gap: 12px;
2723
+ z-index: 9999;
2724
+ pointer-events: none;
2725
+ }
2726
+
2727
+ .toast {
2728
+ display: flex;
2729
+ align-items: center;
2730
+ gap: 12px;
2731
+ padding: 12px 20px;
2732
+ background: var(--bg-card);
2733
+ border: none;
2734
+ border-radius: 100px;
2735
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
2736
+ transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
2737
+ pointer-events: auto;
2738
+ }
2739
+
2740
+ .toast-success {
2741
+ box-shadow: 0 8px 32px var(--acc-d2);
2742
+ }
2743
+
2744
+ .toast-success .toast-icon {
2745
+ color: var(--acc);
2746
+ }
2747
+
2748
+ .toast-error {
2749
+ border-color: var(--neg);
2750
+ }
2751
+
2752
+ .toast-error .toast-icon {
2753
+ color: var(--neg);
2754
+ }
2755
+
2756
+ .toast-icon {
2757
+ display: flex;
2758
+ }
2759
+
2760
+ .toast-icon svg {
2761
+ width: 18px;
2762
+ height: 18px;
2763
+ }
2764
+
2765
+ .toast-message {
2766
+ font-size: 13.5px;
2767
+ font-weight: 600;
2768
+ color: var(--tx1);
2769
+ }
2770
+
2771
+ .toast-persist {
2772
+ align-items: flex-start;
2773
+ padding: 16px 20px;
2774
+ border-radius: 16px;
2775
+ border: none;
2776
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
2777
+ background: var(--bg-card);
2778
+ }
2779
+
2780
+ .toast-persist .toast-icon svg {
2781
+ width: 24px;
2782
+ height: 24px;
2783
+ }
2784
+
2785
+ @keyframes toastSwipeIn {
2786
+ from {
2787
+ opacity: 0;
2788
+ transform: translateY(30px) scale(0.9);
2789
+ }
2790
+
2791
+ to {
2792
+ opacity: 1;
2793
+ transform: translateY(0) scale(1);
2794
+ }
2795
+ }
2796
+
2797
+ @keyframes toastSwipeOut {
2798
+ from {
2799
+ opacity: 1;
2800
+ transform: translateY(0) scale(1);
2801
+ }
2802
+
2803
+ to {
2804
+ opacity: 0;
2805
+ transform: translateY(-20px) scale(0.9);
2806
+ }
2807
+ }
2808
+
2809
+ .toast-enter {
2810
+ opacity: 0;
2811
+ transform: translateY(20px) scale(0.9);
2812
+ }
2813
+
2814
+ .toast-exit {
2815
+ opacity: 0;
2816
+ transform: translateY(-10px) scale(0.9);
2817
+ }
2818
+
2819
+ /* ══════════════════════════════════════════════════
2820
+ MODAL
2821
+ ══════════════════════════════════════════════════ */
2822
+ .modal-overlay {
2823
+ position: fixed;
2824
+ inset: 0;
2825
+ background: rgba(0, 0, 0, 0.6);
2826
+ backdrop-filter: blur(4px);
2827
+ -webkit-backdrop-filter: blur(4px);
2828
+ z-index: 10000;
2829
+ display: flex;
2830
+ align-items: center;
2831
+ justify-content: center;
2832
+ opacity: 0;
2833
+ visibility: hidden;
2834
+ transition: all 0.3s ease;
2835
+ padding: 24px;
2836
+ }
2837
+
2838
+ .modal-overlay.active {
2839
+ opacity: 1;
2840
+ visibility: visible;
2841
+ }
2842
+
2843
+ .modal-box {
2844
+ background: var(--bg-card);
2845
+ border: none;
2846
+ border-radius: 24px; /* More compact rounding */
2847
+ padding: 32px 24px 28px; /* Balanced paddings */
2848
+ width: 100%;
2849
+ max-width: 360px; /* Thinner modal */
2850
+ margin: auto; /* Force centering within flex */
2851
+ display: flex;
2852
+ flex-direction: column;
2853
+ align-items: center;
2854
+ text-align: center;
2855
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
2856
+ transform: translateY(20px) scale(0.95);
2857
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
2858
+ }
2859
+
2860
+ .modal-overlay.active .modal-box {
2861
+ transform: translateY(0) scale(1);
2862
+ }
2863
+
2864
+ .modal-icon-wrap {
2865
+ width: 48px; /* Smaller icon container */
2866
+ height: 48px;
2867
+ border-radius: 50%;
2868
+ display: flex;
2869
+ align-items: center;
2870
+ justify-content: center;
2871
+ margin: 0 auto 16px;
2872
+ }
2873
+
2874
+ .modal-icon-wrap svg {
2875
+ width: 24px; /* Smaller icon */
2876
+ height: 24px;
2877
+ }
2878
+
2879
+ .modal-success .modal-icon-wrap {
2880
+ background: var(--acc-d);
2881
+ color: var(--acc);
2882
+ }
2883
+
2884
+ .modal-error .modal-icon-wrap {
2885
+ background: var(--neg-d);
2886
+ color: var(--neg);
2887
+ border: 1px solid var(--neg-dim);
2888
+ }
2889
+
2890
+ .modal-title {
2891
+ font-size: 19px; /* Neater, smaller title */
2892
+ font-weight: 800;
2893
+ color: var(--tx1);
2894
+ margin: 0 0 10px;
2895
+ letter-spacing: -0.5px;
2896
+ line-height: 1.3;
2897
+ }
2898
+
2899
+ .modal-desc {
2900
+ font-size: 13.5px; /* Compact description */
2901
+ color: var(--tx2);
2902
+ line-height: 1.6;
2903
+ margin: 0 0 24px;
2904
+ }
2905
+
2906
+ .modal-btns {
2907
+ display: flex;
2908
+ gap: 10px;
2909
+ }
2910
+
2911
+ .modal-btn {
2912
+ flex: 1;
2913
+ padding: 10px 18px; /* Tighter padding */
2914
+ font-size: 13.5px; /* Smaller button text */
2915
+ font-weight: 600;
2916
+ justify-content: center;
2917
+ border-radius: 12px;
2918
+ }
2919
+
2920
+ .modal-btn-cancel {
2921
+ background: transparent;
2922
+ border: 1px solid var(--border);
2923
+ color: var(--tx2);
2924
+ }
2925
+
2926
+ .modal-btn-cancel:hover {
2927
+ background: var(--nav-hover-bg);
2928
+ color: var(--tx1);
2929
+ border-color: var(--border-focus);
2930
+ }
2931
+
2932
+ .modal-btn-danger {
2933
+ background: var(--neg);
2934
+ color: #fff;
2935
+ border: none;
2936
+ }
2937
+
2938
+ .modal-btn-danger:hover {
2939
+ background: #ef4444;
2940
+ box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
2941
+ }
2942
+
2943
+ /* ══════════════════════════════════════════════════
2944
+ EMPTY STATE (NO DATA)
2945
+ ══════════════════════════════════════════════════ */
2946
+ .empty-state {
2947
+ display: flex;
2948
+ flex-direction: column;
2949
+ align-items: center;
2950
+ justify-content: center;
2951
+ padding: 40px 20px;
2952
+ text-align: center;
2953
+ flex: 1;
2954
+ height: 100%;
2955
+ min-height: 180px;
2956
+ width: 100%;
2957
+ background: var(--bg-card2);
2958
+ border-radius: var(--r2);
2959
+ border: 1px dashed var(--border);
2960
+ margin-top: auto;
2961
+ margin-bottom: auto;
2962
+ }
2963
+
2964
+ .empty-title {
2965
+ font-size: 14px;
2966
+ font-weight: 600;
2967
+ color: var(--tx1);
2968
+ margin-bottom: 4px;
2969
+ }
2970
+
2971
+ .empty-desc {
2972
+ font-size: 13px;
2973
+ color: var(--tx3);
2974
+ }
2975
+
2976
+ /* ══════════════════════════════════════════════════
2977
+ ANALYTICS PAGE — TABLES
2978
+ ══════════════════════════════════════════════════ */
2979
+ .analytics-table-wrap {
2980
+ overflow-x: auto;
2981
+ border-radius: var(--r2);
2982
+ max-height: 340px;
2983
+ overflow-y: auto;
2984
+ }
2985
+
2986
+ .analytics-table {
2987
+ width: 100%;
2988
+ border-collapse: collapse;
2989
+ font-size: 12.5px;
2990
+ }
2991
+
2992
+ .analytics-table thead tr {
2993
+ position: sticky;
2994
+ top: 0;
2995
+ background: var(--bg-card2);
2996
+ z-index: 1;
2997
+ }
2998
+
2999
+ .analytics-table th {
3000
+ text-align: center;
3001
+ /* Changed from left to center */
3002
+ padding: 9px 12px;
3003
+ font-size: 10.5px;
3004
+ font-weight: 600;
3005
+ text-transform: uppercase;
3006
+ letter-spacing: 0.5px;
3007
+ color: var(--tx3);
3008
+ border-bottom: 1px solid var(--border);
3009
+ white-space: nowrap;
3010
+ }
3011
+
3012
+ .analytics-table th:nth-child(2) {
3013
+ text-align: left;
3014
+ /* Keep Label/Hashtag/Topic name column left-aligned */
3015
+ }
3016
+
3017
+ .analytics-table td {
3018
+ padding: 8px 12px;
3019
+ border-bottom: 1px solid var(--border-s);
3020
+ color: var(--tx1);
3021
+ vertical-align: middle;
3022
+ text-align: center;
3023
+ /* Center all column contents by default */
3024
+ }
3025
+
3026
+ .analytics-table td.at-label {
3027
+ text-align: left;
3028
+ /* Override for text labels */
3029
+ }
3030
+
3031
+ .analytics-table tr:last-child td {
3032
+ border-bottom: none;
3033
+ }
3034
+
3035
+ .analytics-table tr:hover td {
3036
+ background: var(--nav-hover-bg);
3037
+ }
3038
+
3039
+ .analytics-table .tc-pos {
3040
+ color: var(--pos);
3041
+ }
3042
+
3043
+ .analytics-table .tc-neg {
3044
+ color: var(--neg);
3045
+ }
3046
+
3047
+ .analytics-table .tc-neu {
3048
+ color: var(--neu);
3049
+ }
3050
+
3051
+ .at-rank {
3052
+ color: var(--tx3);
3053
+ font-size: 11px;
3054
+ font-weight: 600;
3055
+ width: 28px;
3056
+ min-width: 28px;
3057
+ text-align: center;
3058
+ }
3059
+
3060
+ .at-num {
3061
+ text-align: center;
3062
+ /* Changed from right to center */
3063
+ font-weight: 600;
3064
+ font-variant-numeric: tabular-nums;
3065
+ min-width: 48px;
3066
+ }
3067
+
3068
+ .at-label {
3069
+ font-weight: 500;
3070
+ }
3071
+
3072
+ .at-topic-text {
3073
+ max-width: 300px;
3074
+ overflow: hidden;
3075
+ text-overflow: ellipsis;
3076
+ white-space: nowrap;
3077
+ }
3078
+
3079
+ .at-hashtag {
3080
+ display: inline-flex;
3081
+ padding: 2px 8px;
3082
+ background: var(--acc-d);
3083
+ color: var(--acc);
3084
+ border-radius: 100px;
3085
+ font-size: 11.5px;
3086
+ font-weight: 600;
3087
+ }
3088
+
3089
+ .at-bar-cell {
3090
+ min-width: 100px;
3091
+ padding-right: 16px;
3092
+ }
3093
+
3094
+ .at-bar-track {
3095
+ height: 6px;
3096
+ border-radius: 100px;
3097
+ background: var(--bg-card2);
3098
+ overflow: hidden;
3099
+ }
3100
+
3101
+ .at-bar-multi {
3102
+ display: flex;
3103
+ height: 6px;
3104
+ }
3105
+
3106
+ .at-bar-fill {
3107
+ height: 100%;
3108
+ border-radius: 100px;
3109
+ transition: width .4s ease;
3110
+ }
3111
+
3112
+ .analytics-table-empty {
3113
+ display: none;
3114
+ padding: 24px;
3115
+ text-align: center;
3116
+ color: var(--tx3);
3117
+ font-size: 12.5px;
3118
+ font-style: italic;
3119
+ }
3120
+
3121
+ /* ══════════════════════════════════════════════════
3122
+ ANALYTICS PAGE — WORD TAGS
3123
+ ══════════════════════════════════════════════════ */
3124
+ .word-tag-cloud {
3125
+ display: flex;
3126
+ flex-wrap: wrap;
3127
+ gap: 6px;
3128
+ padding: 8px 0;
3129
+ min-height: 140px;
3130
+ align-content: flex-start;
3131
+ }
3132
+
3133
+ .word-tag {
3134
+ display: inline-flex;
3135
+ align-items: center;
3136
+ padding: 4px 10px;
3137
+ border-radius: 100px;
3138
+ border: 1px solid transparent;
3139
+ font-weight: 600;
3140
+ cursor: default;
3141
+ transition: transform var(--t-fast), opacity var(--t-fast);
3142
+ line-height: 1;
3143
+ white-space: nowrap;
3144
+ }
3145
+
3146
+ .word-tag:hover {
3147
+ transform: scale(1.08);
3148
+ opacity: 1 !important;
3149
+ }
3150
+
3151
+ /* ══════════════════════════════════════════════════
3152
+ TWEET LIST PAGE (tl-*)
3153
+ ══════════════════════════════════════════════════ */
3154
+
3155
+ /* ── Filter bar ── */
3156
+ .tl-filter-bar {
3157
+ display: flex;
3158
+ flex-direction: column;
3159
+ align-items: center;
3160
+ justify-content: center;
3161
+ gap: 16px;
3162
+ margin-bottom: 24px;
3163
+ padding: 24px;
3164
+ background: var(--bg-card);
3165
+ border: 1px solid var(--border);
3166
+ border-radius: var(--r3);
3167
+ box-shadow: var(--shadow-sm);
3168
+ text-align: center;
3169
+ }
3170
+
3171
+ .tl-filter-tabs {
3172
+ display: grid;
3173
+ grid-template-columns: repeat(3, 1fr);
3174
+ gap: 8px;
3175
+ width: 100%;
3176
+ max-width: 380px;
3177
+ }
3178
+
3179
+ #btnAll {
3180
+ grid-column: span 3;
3181
+ }
3182
+
3183
+ .tl-filter-actions {
3184
+ display: flex;
3185
+ flex-direction: column;
3186
+ gap: 12px;
3187
+ width: 100%;
3188
+ max-width: 380px;
3189
+ }
3190
+
3191
+ .tl-tab {
3192
+ display: inline-flex;
3193
+ flex-direction: column;
3194
+ align-items: center;
3195
+ justify-content: center;
3196
+ gap: 4px;
3197
+ padding: 8px 12px;
3198
+ border-radius: 12px;
3199
+ border: 1px solid var(--border);
3200
+ background: var(--bg-card2);
3201
+ font-size: 13px;
3202
+ font-weight: 600;
3203
+ color: var(--tx2);
3204
+ cursor: pointer;
3205
+ font-family: var(--font);
3206
+ transition: all var(--t-fast);
3207
+ min-width: 80px;
3208
+ height: auto;
3209
+ }
3210
+
3211
+ .tl-tab-label-group {
3212
+ display: flex;
3213
+ align-items: center;
3214
+ gap: 6px;
3215
+ font-size: 11px;
3216
+ color: var(--tx1);
3217
+ opacity: 0.9;
3218
+ }
3219
+
3220
+ .tl-tab:hover {
3221
+ background: var(--nav-hover-bg);
3222
+ border-color: var(--border-focus);
3223
+ transform: translateY(-1px);
3224
+ }
3225
+
3226
+ .tl-tab.active {
3227
+ background: var(--acc-d2);
3228
+ color: var(--acc);
3229
+ border-color: var(--acc);
3230
+ box-shadow: 0 4px 12px var(--acc-d);
3231
+ }
3232
+
3233
+ .tl-tab.active .tl-tab-label-group {
3234
+ color: var(--acc);
3235
+ }
3236
+
3237
+ .tl-tab-dot {
3238
+ width: 7px;
3239
+ height: 7px;
3240
+ border-radius: 50%;
3241
+ flex-shrink: 0;
3242
+ }
3243
+
3244
+ .tl-tab-count {
3245
+ font-size: 15px;
3246
+ font-weight: 800;
3247
+ color: var(--tx1);
3248
+ line-height: 1;
3249
+ }
3250
+
3251
+ .tl-tab.active .tl-tab-count {
3252
+ color: var(--acc);
3253
+ }
3254
+
3255
+ /* ── Search ── */
3256
+ .tl-search-wrap {
3257
+ position: relative;
3258
+ width: 100%;
3259
+ max-width: 380px;
3260
+ }
3261
+
3262
+ .tl-search-icon {
3263
+ position: absolute;
3264
+ left: 11px;
3265
+ top: 50%;
3266
+ transform: translateY(-50%);
3267
+ width: 14px;
3268
+ height: 14px;
3269
+ color: var(--tx3);
3270
+ pointer-events: none;
3271
+ }
3272
+
3273
+ .tl-search {
3274
+ width: 100%;
3275
+ padding: 7px 34px 7px 32px;
3276
+ background: var(--bg-input);
3277
+ border: 1px solid var(--border);
3278
+ border-radius: var(--r2);
3279
+ color: var(--tx1);
3280
+ font-size: 13px;
3281
+ font-family: var(--font);
3282
+ outline: none;
3283
+ transition: border-color var(--t-fast);
3284
+ }
3285
+
3286
+ .tl-search:focus {
3287
+ border-color: var(--border-focus);
3288
+ }
3289
+
3290
+ .tl-search-clear {
3291
+ position: absolute;
3292
+ right: 8px;
3293
+ top: 50%;
3294
+ transform: translateY(-50%);
3295
+ display: none;
3296
+ align-items: center;
3297
+ justify-content: center;
3298
+ width: 20px;
3299
+ height: 20px;
3300
+ border-radius: 50%;
3301
+ border: none;
3302
+ background: var(--bg-card2);
3303
+ color: var(--tx3);
3304
+ font-size: 10px;
3305
+ cursor: pointer;
3306
+ transition: background var(--t-fast);
3307
+ }
3308
+
3309
+ .tl-search-clear:hover {
3310
+ background: var(--neg-d);
3311
+ color: var(--neg);
3312
+ }
3313
+
3314
+ /* ── Sort (inherits shared select styles above) ── */
3315
+ .tl-sort-wrap {
3316
+ position: relative;
3317
+ display: flex;
3318
+ justify-content: center;
3319
+ width: 100%;
3320
+ }
3321
+
3322
+ .tl-sort-sel {
3323
+ white-space: nowrap;
3324
+ min-width: 150px;
3325
+ }
3326
+
3327
+ /* ── Stat Pills ── */
3328
+ .tl-stat-pill {
3329
+ display: inline-flex;
3330
+ align-items: center;
3331
+ padding: 4px 12px;
3332
+ border-radius: 100px;
3333
+ font-size: 11.5px;
3334
+ font-weight: 600;
3335
+ }
3336
+
3337
+ /* ── Feed grid ── */
3338
+ .tl-feed {
3339
+ display: flex;
3340
+ flex-direction: column;
3341
+ gap: 12px;
3342
+ margin-bottom: 20px;
3343
+ }
3344
+
3345
+ /* ── Tweet Card ── */
3346
+ .tl-card {
3347
+ background: var(--bg-card);
3348
+ border: 1px solid var(--border);
3349
+ border-radius: var(--r2);
3350
+ padding: 14px 16px 10px;
3351
+ box-shadow: none;
3352
+ transition: border-color var(--t-fast);
3353
+ border-left: 3px solid transparent;
3354
+ position: relative;
3355
+ }
3356
+
3357
+ .tl-card:hover {
3358
+ border-color: var(--border-s);
3359
+ }
3360
+
3361
+ .tl-card-pos {
3362
+ border-left-color: var(--pos);
3363
+ }
3364
+
3365
+ .tl-card-neg {
3366
+ border-left-color: var(--neg);
3367
+ }
3368
+
3369
+ .tl-card-neu {
3370
+ border-left-color: var(--neu);
3371
+ }
3372
+
3373
+ /* Card Header */
3374
+ .tl-card-header {
3375
+ display: flex;
3376
+ align-items: flex-start;
3377
+ gap: 10px;
3378
+ margin-bottom: 8px;
3379
+ flex-wrap: wrap; /* allow wrapping if deeply compressed */
3380
+ }
3381
+
3382
+ .tl-avatar {
3383
+ width: 32px;
3384
+ height: 32px;
3385
+ border-radius: 50%;
3386
+ display: flex;
3387
+ align-items: center;
3388
+ justify-content: center;
3389
+ font-size: 11px;
3390
+ font-weight: 700;
3391
+ color: #fff;
3392
+ flex-shrink: 0;
3393
+ letter-spacing: -0.2px;
3394
+ }
3395
+
3396
+ .tl-user-info {
3397
+ display: flex;
3398
+ flex-direction: column;
3399
+ /* Changed back to column for safe stacking over locations */
3400
+ gap: 3px;
3401
+ flex: 1;
3402
+ min-width: 0;
3403
+ }
3404
+
3405
+ .tl-user-row {
3406
+ display: flex;
3407
+ align-items: center;
3408
+ flex-wrap: wrap; /* wrap date next to name if needed */
3409
+ gap: 6px;
3410
+ }
3411
+
3412
+ .tl-username {
3413
+ font-size: 13.5px;
3414
+ font-weight: 700;
3415
+ color: var(--tx1);
3416
+ word-break: break-all;
3417
+ }
3418
+
3419
+ .tl-date {
3420
+ font-size: 11.5px;
3421
+ color: var(--tx3);
3422
+ white-space: nowrap;
3423
+ }
3424
+
3425
+ .tl-date-link {
3426
+ color: inherit;
3427
+ text-decoration: none;
3428
+ display: inline-flex;
3429
+ align-items: center;
3430
+ gap: 4px;
3431
+ transition: color var(--t-fast);
3432
+ }
3433
+
3434
+ .tl-date-link:hover {
3435
+ color: var(--acc);
3436
+ text-decoration: underline;
3437
+ }
3438
+
3439
+ .tl-date-link svg {
3440
+ width: 12px;
3441
+ height: 12px;
3442
+ }
3443
+
3444
+ .tl-location {
3445
+ font-size: 11px;
3446
+ color: var(--tx3);
3447
+ }
3448
+
3449
+ .tl-header-right {
3450
+ display: inline-flex;
3451
+ align-items: center;
3452
+ gap: 6px;
3453
+ flex-shrink: 0;
3454
+ align-self: flex-start; /* keep badges Top-Right */
3455
+ }
3456
+
3457
+ .tl-sent-badge {
3458
+ font-size: 10px;
3459
+ padding: 2px 8px;
3460
+ }
3461
+
3462
+ .tl-link-btn {
3463
+ display: inline-flex;
3464
+ align-items: center;
3465
+ justify-content: center;
3466
+ width: 22px;
3467
+ height: 22px;
3468
+ color: var(--tx3);
3469
+ background: transparent;
3470
+ border: 1px solid transparent;
3471
+ border-radius: 4px;
3472
+ transition: all var(--t-fast);
3473
+ }
3474
+
3475
+ .tl-link-btn:hover {
3476
+ color: var(--tx1);
3477
+ background: var(--bg-input);
3478
+ }
3479
+
3480
+ .tl-link-btn svg {
3481
+ width: 12px;
3482
+ height: 12px;
3483
+ }
3484
+
3485
+ /* Tweet text */
3486
+ .tl-tweet-text {
3487
+ font-size: 13.5px;
3488
+ color: var(--tx1);
3489
+ line-height: 1.45;
3490
+ margin-bottom: 8px;
3491
+ word-break: break-word;
3492
+ }
3493
+
3494
+ .tl-highlight {
3495
+ background: var(--neu-d);
3496
+ color: var(--neu);
3497
+ border-radius: 3px;
3498
+ padding: 0 2px;
3499
+ }
3500
+
3501
+ /* Hashtag tags */
3502
+ .tl-tags-row {
3503
+ display: flex;
3504
+ flex-wrap: wrap;
3505
+ gap: 5px;
3506
+ margin-bottom: 8px;
3507
+ }
3508
+
3509
+ .tl-hashtag {
3510
+ display: inline-flex;
3511
+ padding: 2px 8px;
3512
+ background: var(--acc-d);
3513
+ color: var(--acc);
3514
+ border-radius: 100px;
3515
+ font-size: 11px;
3516
+ font-weight: 600;
3517
+ transition: background var(--t-fast);
3518
+ }
3519
+
3520
+ .tl-hashtag:hover {
3521
+ background: var(--acc-d2);
3522
+ }
3523
+
3524
+ /* Media */
3525
+ .tl-media {
3526
+ margin-top: 6px;
3527
+ margin-bottom: 10px;
3528
+ border-radius: 8px;
3529
+ overflow: hidden;
3530
+ border: 1px solid var(--border);
3531
+ background: var(--bg-card2);
3532
+ display: inline-flex;
3533
+ /* Changed so it doesn't span full width if image is small */
3534
+ align-items: flex-start;
3535
+ justify-content: flex-start;
3536
+ }
3537
+
3538
+ .tl-media-img {
3539
+ max-width: 100%;
3540
+ max-height: 280px;
3541
+ /* Slightly shorter */
3542
+ object-fit: contain;
3543
+ display: block;
3544
+ transition: opacity var(--t-fast);
3545
+ }
3546
+
3547
+ .tl-media-img:hover {
3548
+ opacity: 0.95;
3549
+ }
3550
+
3551
+ /* Card Footer */
3552
+ .tl-card-footer {
3553
+ display: flex;
3554
+ align-items: center;
3555
+ justify-content: space-between;
3556
+ gap: 16px;
3557
+ padding-top: 8px;
3558
+ border-top: 1px solid var(--border-s);
3559
+ flex-wrap: wrap;
3560
+ }
3561
+
3562
+ .tl-engage-row {
3563
+ display: flex;
3564
+ gap: 16px;
3565
+ }
3566
+
3567
+ .tl-engage-item {
3568
+ display: inline-flex;
3569
+ align-items: center;
3570
+ gap: 4px;
3571
+ font-size: 11.5px;
3572
+ color: var(--tx3);
3573
+ font-weight: 500;
3574
+ font-variant-numeric: tabular-nums;
3575
+ transition: color var(--t-fast);
3576
+ }
3577
+
3578
+ .tl-engage-item:hover {
3579
+ color: var(--tx2);
3580
+ }
3581
+
3582
+ .tl-engage-item svg {
3583
+ width: 14px;
3584
+ height: 14px;
3585
+ flex-shrink: 0;
3586
+ }
3587
+
3588
+ /* Confidence */
3589
+ .tl-conf-section {
3590
+ display: flex;
3591
+ align-items: center;
3592
+ gap: 8px;
3593
+ }
3594
+
3595
+ .tl-conf-label {
3596
+ font-size: 10.5px;
3597
+ color: var(--tx3);
3598
+ font-weight: 600;
3599
+ text-transform: uppercase;
3600
+ letter-spacing: 0.5px;
3601
+ white-space: nowrap;
3602
+ }
3603
+
3604
+ .tl-conf-wrap {
3605
+ display: flex;
3606
+ align-items: center;
3607
+ gap: 8px;
3608
+ }
3609
+
3610
+ .tl-conf-val {
3611
+ font-size: 12px;
3612
+ font-weight: 700;
3613
+ color: var(--tx1);
3614
+ font-variant-numeric: tabular-nums;
3615
+ min-width: 40px;
3616
+ text-align: right;
3617
+ }
3618
+
3619
+ .tl-conf-track {
3620
+ width: 80px;
3621
+ height: 5px;
3622
+ border-radius: 100px;
3623
+ background: var(--bg-card2);
3624
+ overflow: hidden;
3625
+ }
3626
+
3627
+ .tl-conf-fill {
3628
+ height: 100%;
3629
+ border-radius: 100px;
3630
+ transition: width .4s ease;
3631
+ }
3632
+
3633
+ .conf-fill-pos {
3634
+ background: var(--pos);
3635
+ }
3636
+
3637
+ .conf-fill-neg {
3638
+ background: var(--neg);
3639
+ }
3640
+
3641
+ .conf-fill-neu {
3642
+ background: var(--neu);
3643
+ }
3644
+
3645
+ /* ── Pagination ── */
3646
+ .tl-pagination-wrap {
3647
+ display: flex;
3648
+ align-items: center;
3649
+ justify-content: space-between;
3650
+ flex-wrap: wrap;
3651
+ gap: 12px;
3652
+ padding-top: 8px;
3653
+ }
3654
+
3655
+ .tl-info {
3656
+ font-size: 12px;
3657
+ color: var(--tx3);
3658
+ }
3659
+
3660
+ .tl-pagination {
3661
+ display: flex;
3662
+ gap: 4px;
3663
+ flex-wrap: wrap;
3664
+ }
3665
+
3666
+ /* ── Empty state ── */
3667
+ .tl-empty {
3668
+ display: flex;
3669
+ flex-direction: column;
3670
+ align-items: center;
3671
+ justify-content: center;
3672
+ padding: 60px 24px;
3673
+ text-align: center;
3674
+ background: var(--bg-card);
3675
+ border: 1px dashed var(--border);
3676
+ border-radius: var(--r3);
3677
+ }
3678
+
3679
+ .tl-empty-icon {
3680
+ font-size: 40px;
3681
+ margin-bottom: 12px;
3682
+ }
3683
+
3684
+ .tl-empty-title {
3685
+ font-size: 15px;
3686
+ font-weight: 600;
3687
+ color: var(--tx1);
3688
+ margin-bottom: 4px;
3689
+ }
3690
+
3691
+ .tl-empty-sub {
3692
+ font-size: 12.5px;
3693
+ color: var(--tx3);
3694
+ }
3695
+ /* -- Desktop Optimization -- */
3696
+ @media (min-width: 1025px) {
3697
+ .tl-filter-bar {
3698
+ flex-direction: row;
3699
+ justify-content: space-between;
3700
+ padding: 16px 20px;
3701
+ text-align: left;
3702
+ gap: 12px;
3703
+ }
3704
+
3705
+ .tl-filter-tabs {
3706
+ display: flex;
3707
+ flex-direction: row;
3708
+ flex-wrap: nowrap;
3709
+ width: auto;
3710
+ max-width: none;
3711
+ gap: 6px;
3712
+ }
3713
+
3714
+ #btnAll {
3715
+ grid-column: auto;
3716
+ min-width: 80px;
3717
+ }
3718
+
3719
+ .tl-tab {
3720
+ flex-direction: row;
3721
+ padding: 6px 14px;
3722
+ height: 38px;
3723
+ min-width: auto;
3724
+ gap: 10px;
3725
+ }
3726
+
3727
+ .tl-tab-label-group {
3728
+ order: 1;
3729
+ }
3730
+
3731
+ .tl-tab-count {
3732
+ order: 2;
3733
+ font-size: 13px;
3734
+ margin-left: 4px;
3735
+ }
3736
+
3737
+ .tl-tab-label {
3738
+ font-size: 13px;
3739
+ font-weight: 600;
3740
+ }
3741
+
3742
+ .tl-filter-actions {
3743
+ flex-direction: row;
3744
+ width: auto;
3745
+ max-width: none;
3746
+ align-items: center;
3747
+ gap: 10px;
3748
+ }
3749
+
3750
+ .tl-search-wrap {
3751
+ width: 240px;
3752
+ max-width: none;
3753
+ }
3754
+
3755
+ .tl-sort-wrap {
3756
+ width: auto;
3757
+ min-width: 160px;
3758
+ }
3759
+ }
css/support.css ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .support-grid {
2
+ display: grid;
3
+ grid-template-columns: repeat(3, 1fr);
4
+ gap: 24px;
5
+ margin: 32px 0 0;
6
+ }
7
+
8
+ .support-card {
9
+ background: var(--bg-card);
10
+ border: 1px solid var(--border);
11
+ border-radius: var(--r3);
12
+ padding: 24px 20px;
13
+ text-align: center;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ justify-content: center;
18
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
19
+ box-shadow: var(--shadow-sm);
20
+ width: 100%;
21
+ }
22
+
23
+ .support-card:hover {
24
+ box-shadow: var(--shadow-md);
25
+ }
26
+
27
+ .sup-icon-box {
28
+ width: 44px; /* Slightly smaller icon box */
29
+ height: 44px;
30
+ border-radius: 12px;
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ margin-bottom: 18px;
35
+ }
36
+
37
+ .sup-icon-box svg {
38
+ width: 20px; /* Slightly smaller icon */
39
+ height: 20px;
40
+ }
41
+
42
+ .sup-title {
43
+ font-size: 16px; /* Slightly smaller title */
44
+ font-weight: 800;
45
+ color: var(--tx1);
46
+ margin-bottom: 8px;
47
+ letter-spacing: -0.2px;
48
+ }
49
+
50
+ .sup-desc {
51
+ font-size: 11.5px; /* Slightly smaller */
52
+ color: var(--tx3);
53
+ line-height: 1.5;
54
+ margin-bottom: 24px;
55
+ max-width: 280px;
56
+ }
57
+
58
+ .sup-btn {
59
+ width: fit-content;
60
+ min-width: 170px; /* Tighter button */
61
+ justify-content: center;
62
+ font-size: 11.5px;
63
+ height: 38px; /* Standard premium height */
64
+ font-weight: 700;
65
+ padding: 0 24px;
66
+ border-radius: 10px;
67
+ }
68
+
69
+ .sup-links {
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 8px;
73
+ width: 100%;
74
+ align-items: center;
75
+ }
76
+
77
+ .sup-link-item {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 10px;
81
+ height: 38px;
82
+ padding: 0 20px;
83
+ background: var(--bg-card);
84
+ border: none;
85
+ border-radius: 10px;
86
+ color: var(--tx2);
87
+ font-size: 11.5px;
88
+ font-weight: 600;
89
+ transition: all 0.3s ease;
90
+ width: fit-content;
91
+ min-width: 170px;
92
+ }
93
+
94
+ .sup-link-item:hover {
95
+ background: var(--nav-hover-bg);
96
+ color: var(--tx1);
97
+ }
98
+
99
+ .sup-link-icon {
100
+ display: flex;
101
+ opacity: 0.9;
102
+ }
103
+
104
+ /* GitHub Brand Style */
105
+ .sup-link-github {
106
+ background: #24292e;
107
+ color: #ffffff;
108
+ border-color: #24292e;
109
+ }
110
+ .sup-link-github .sup-link-icon { color: #ffffff; }
111
+ .sup-link-github:hover {
112
+ background: #1b1f23;
113
+ color: #ffffff;
114
+ }
115
+
116
+ /* Instagram Brand Style */
117
+ .sup-link-insta {
118
+ background: #fdf2f8;
119
+ color: #be185d;
120
+ border-color: #fce7f3;
121
+ }
122
+ .sup-link-insta .sup-link-icon { color: #db2777; }
123
+ .sup-link-insta:hover {
124
+ background: #fce7f3;
125
+ color: #9d174d;
126
+ }
127
+
128
+ .wa-num {
129
+ margin-top: 8px;
130
+ font-size: 11px;
131
+ color: var(--tx3);
132
+ font-weight: 600;
133
+ letter-spacing: 0.5px;
134
+ }
135
+
136
+ @media (max-width: 900px) {
137
+ .support-grid {
138
+ grid-template-columns: 1fr;
139
+ max-width: 450px;
140
+ }
141
+ }
dashboard.html ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1.0" />
5
+ <title>Dashboard — SentiMeter</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" /><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
8
+ <script src="js/chart.js"></script>
9
+ <link rel="stylesheet" href="css/style.css" />
10
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
11
+ </head>
12
+ <body>
13
+ <div class="layout">
14
+ <div id="sidebar"></div>
15
+ <div class="main">
16
+ <div class="topbar">
17
+ <div class="topbar-title">Dashboard Eksekutif</div>
18
+ <div id="topbarMeta" class="topbar-sub"></div>
19
+ </div>
20
+ <div class="page-body">
21
+
22
+ <!-- KPI CARDS -->
23
+ <div class="sec-head" style="margin-bottom:14px">
24
+ <div><div class="sec-title">Ringkasan Analisis</div><div class="sec-sub" id="datePeriod"></div></div>
25
+ <div class="actions">
26
+ <button class="btn btn-ghost btn-sm" onclick="location.href='upload.html'">Upload Baru</button>
27
+ </div>
28
+ </div>
29
+ <div class="kpi-grid" id="kpiGrid"></div>
30
+
31
+ <!-- GAUGE + DONUT + TIME -->
32
+ <div class="chart-grid" style="margin-bottom:14px">
33
+ <div class="card card-body">
34
+ <div class="card-head"><div class="card-title">Skor Sentimen Global</div><span class="card-badge">Gauge</span></div>
35
+ <div class="gauge-wrap"><canvas id="chartGauge" width="240" height="140"></canvas></div>
36
+ <div id="gaugeLabel" style="text-align:center;font-size:13px;color:var(--tx2);margin-top:6px"></div>
37
+ </div>
38
+ <div class="card card-body">
39
+ <div class="card-head"><div class="card-title">Distribusi Sentimen</div><span class="card-badge">Donut</span></div>
40
+ <div class="chart-wrap chart-wrap-sm"><canvas id="chartDonut"></canvas></div>
41
+ <div class="legend-row" id="legendDonut"></div>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- TREND -->
46
+ <div class="card card-body" style="margin-bottom:14px">
47
+ <div class="card-head"><div class="card-title">Tren Sentimen per Waktu</div><span class="card-badge">Garis</span></div>
48
+ <div class="chart-wrap"><canvas id="chartTrend"></canvas></div>
49
+ </div>
50
+
51
+ <!-- TOP TWEETS + LEADERBOARD -->
52
+ <div class="chart-grid" style="margin-bottom:14px">
53
+ <div class="card card-body">
54
+ <div class="card-head"><div class="card-title">Tweet Positif Teratas</div><span class="card-badge">Top 5</span></div>
55
+ <div id="positiveTweets"></div>
56
+ </div>
57
+ <div class="card card-body">
58
+ <div class="card-head"><div class="card-title">Tweet Negatif Teratas</div><span class="card-badge">Top 5</span></div>
59
+ <div id="negativeTweets"></div>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- ENGAGEMENT + INSIGHTS -->
64
+ <div class="chart-grid">
65
+ <div class="card card-body">
66
+ <div class="card-head"><div class="card-title">Engagement Leaderboard</div><span class="card-badge">Top 10</span></div>
67
+ <div id="leaderboard"></div>
68
+ </div>
69
+ <div class="card card-body">
70
+ <div class="card-head"><div class="card-title">Insight Otomatis</div><span class="card-badge">AI</span></div>
71
+ <div class="insight-list" id="insights"></div>
72
+ </div>
73
+ </div>
74
+
75
+ </div>
76
+ </div>
77
+ </div>
78
+ <script src="js/shared.js"></script>
79
+ <script src="js/dashboard.js"></script>
80
+ </body>
81
+ </html>
data.html ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1.0" />
5
+ <title>Data & Tabel — SentiMeter</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" /><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
8
+ <link rel="stylesheet" href="css/style.css" />
9
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
10
+ </head>
11
+ <body>
12
+ <div class="layout">
13
+ <div id="sidebar"></div>
14
+ <div class="main">
15
+ <div class="topbar">
16
+ <div class="topbar-title">Data & Tabel Lengkap</div>
17
+ <div id="topbarMeta" class="topbar-sub"></div>
18
+ </div>
19
+ <div class="page-body">
20
+
21
+ <div class="sec-head">
22
+ <div><div class="sec-title">Hasil Analisis Sentimen</div><div class="sec-sub">Sortable · Multi-filter · Expandable rows</div></div>
23
+ <div class="actions" id="tableActions"></div>
24
+ </div>
25
+
26
+ <!-- Filter Bar -->
27
+ <div class="tl-filter-bar data-page-filters">
28
+ <div class="filter-group">
29
+ <span class="filter-label">Sentimen:</span>
30
+ <select id="fSentiment">
31
+ <option value="all">Semua</option>
32
+ <option value="Positif">Positif</option>
33
+ <option value="Negatif">Negatif</option>
34
+ <option value="Netral">Netral</option>
35
+ </select>
36
+ </div>
37
+ <div class="filter-group">
38
+ <span class="filter-label">Lokasi:</span>
39
+ <select id="fLocation"><option value="all">Semua</option></select>
40
+ </div>
41
+ <div class="filter-group">
42
+ <span class="filter-label">User:</span>
43
+ <select id="fUser"><option value="all">Semua</option></select>
44
+ </div>
45
+ <div class="filter-search-wrap">
46
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="tl-search-icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
47
+ <input type="text" id="fSearch" placeholder="Cari teks..." />
48
+ </div>
49
+ <div class="filter-group mini-inputs">
50
+ <input type="number" id="fMinEngage" placeholder="Min. Engagement" min="0" />
51
+ <input type="number" id="fMinConf" placeholder="Min. Conf %" min="0" max="100" />
52
+ </div>
53
+ <button class="btn btn-ghost btn-mini" id="btnReset">Reset</button>
54
+ </div>
55
+
56
+ <!-- Column visibility -->
57
+ <div class="col-visibility-wrap">
58
+ <div class="col-toggle-label">Kolom Terlihat</div>
59
+ <div class="col-toggle" id="colToggle"></div>
60
+ </div>
61
+
62
+ <!-- Table Area -->
63
+ <div class="table-card">
64
+ <div class="table-wrap">
65
+ <table class="data-table" id="dataTable">
66
+ <thead id="tableHead"></thead>
67
+ <tbody id="tableBody"></tbody>
68
+ </table>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Info & Pagination Stack -->
73
+ <div class="tl-pagination-stack">
74
+ <div class="pagination" id="pagination"></div>
75
+ <div class="tl-pagination-info" id="tableInfo"></div>
76
+ <div class="tl-page-size-row">
77
+ <span class="tl-page-size-label">Baris per halaman:</span>
78
+ <select id="pageSize" class="tl-page-size-sel">
79
+ <option value="10">10</option>
80
+ <option value="20" selected>20</option>
81
+ <option value="50">50</option>
82
+ <option value="100">100</option>
83
+ </select>
84
+ </div>
85
+ </div>
86
+
87
+ </div>
88
+ </div>
89
+ </div>
90
+ <script src="js/shared.js"></script>
91
+ <script src="js/data.js"></script>
92
+ </body>
93
+ </html>
history.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
+ <title>Riwayat Analisis — SentiMeter</title>
8
+ <meta name="description" content="Riwayat Analisis Sentimen IndoBERT" />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="css/style.css" />
11
+ <link rel="stylesheet" href="css/history.css" />
12
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
13
+ </head>
14
+
15
+ <body>
16
+ <div class="layout">
17
+ <div id="sidebar"></div>
18
+ <div class="main">
19
+ <div class="topbar">
20
+ <div class="topbar-title">Riwayat Analisis</div>
21
+ <div class="topbar-sub">Data Analisis Tersimpan</div>
22
+ </div>
23
+ <div class="page-body">
24
+
25
+ <div class="sec-head reveal-up">
26
+ <div>
27
+ <h1 class="sec-title">
28
+ Riwayat Analisis
29
+ <span class="hist-count-badge" id="histCount">0</span>
30
+ </h1>
31
+ <p class="sec-sub">Daftar data analisis sentimen yang pernah Anda jalankan. Anda dapat melihat kembali hasilnya tanpa perlu melakukan upload ualng. Data ini disimpan lokal di perangkat Anda.</p>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="history-container" id="historyContainer">
36
+ <!-- History Cards Inserted Here via JS -->
37
+ </div>
38
+
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <script src="js/shared.js"></script>
44
+ <script src="js/history.js"></script>
45
+ </body>
46
+
47
+ </html>
img/logo.svg ADDED
index.html ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
+ <title>Pengenalan — SentiMeter</title>
8
+ <meta name="description"
9
+ content="Selamat datang di SentiMeter — Platform analisis sentimen teks Indonesia berbasis model IndoBERT." />
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
13
+ rel="stylesheet" />
14
+ <link rel="stylesheet" href="css/style.css" />
15
+ <link rel="stylesheet" href="css/index.css" />
16
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
17
+ </head>
18
+
19
+ <body>
20
+ <div class="layout">
21
+ <div id="sidebar"></div>
22
+ <div class="main">
23
+ <div class="topbar">
24
+ <div class="topbar-title">Pengenalan SentiMeter</div>
25
+ <div class="topbar-sub">Panduan Lengkap Platform</div>
26
+ </div>
27
+ <div class="page-body">
28
+
29
+ <!-- HERO -->
30
+ <section class="ih-hero" id="introHero">
31
+ <div class="ih-hero-glow"></div>
32
+ <div class="ih-hero-inner">
33
+ <div class="ih-badge reveal-up" style="--d:0ms">Model IndoBERT · Bahasa Indonesia</div>
34
+ <h1 class="ih-title reveal-up" style="--d:80ms">
35
+ Selamat Datang di<br>
36
+ <span class="ih-grad">SentiMeter</span>
37
+ </h1>
38
+ <p class="ih-sub reveal-up" style="--d:180ms">
39
+ Platform analisis sentimen Twitter/X Bahasa Indonesia berbasis kecerdasan buatan.<br>
40
+ Keamanan data terjamin dengan <strong>Local Storage</strong> — data Anda tetap di browser Anda.
41
+ </p>
42
+ <div class="ih-disclaimer reveal-up" style="--d:240ms">
43
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
44
+ <span><strong>Mohon Maaf:</strong> Model machine learning IndoBERT saat ini masih dalam tahap pengembangan, sehingga hasil prediksi mungkin belum sepenuhnya akurat dan terkadang terjadi kesalahan klasifikasi.</span>
45
+ </div>
46
+ <div class="ih-hero-btns reveal-up" style="--d:300ms">
47
+ <a href="upload.html" class="btn btn-primary">Mulai Analisis</a>
48
+ <a href="#cara-pakai" class="btn btn-ghost" id="learnMoreBtn">Pelajari cara pakai ↓</a>
49
+ </div>
50
+ <div class="ih-stats reveal-up" style="--d:420ms">
51
+ <div class="ih-stat"><b class="ih-stat-n" data-to="9">0</b><span>Langkah Cleaning</span></div>
52
+ <div class="ih-stat-sep"></div>
53
+ <div class="ih-stat"><b class="ih-stat-n" data-to="14">0</b><span>Grafik Interaktif</span></div>
54
+ <div class="ih-stat-sep"></div>
55
+ <div class="ih-stat"><b class="ih-stat-n" data-to="5">0</b><span>Halaman Analisis</span></div>
56
+ </div>
57
+ </div>
58
+ </section>
59
+
60
+ <!-- CARA PAKAI -->
61
+ <section class="ih-section" id="cara-pakai">
62
+ <div class="ih-sec-head reveal-up">
63
+ <p class="ih-sec-label">Cara Penggunaan</p>
64
+ <h2>Hanya 3 Langkah Mudah</h2>
65
+ <p class="ih-sec-sub">Dari CSV ke insight penuh dalam hitungan menit</p>
66
+ </div>
67
+ <div class="ih-steps reveal-up" style="--d:100ms">
68
+ <div class="ih-step">
69
+ <div class="ih-step-num blue">01</div>
70
+ <h3>Dapatkan Data</h3>
71
+ <p>Gunakan tool scraping eksternal (seperti XScraper) untuk mengumpulkan data dari Twitter/X ke format CSV yang kompatibel.</p>
72
+ </div>
73
+ <div class="ih-step-chevron">›</div>
74
+ <div class="ih-step">
75
+ <div class="ih-step-num green">02</div>
76
+ <h3>Upload File CSV</h3>
77
+ <p>Seret & lepas file CSV ke halaman <strong>Upload Data</strong>. Mendukung file sangat besar (1GB+) dan
78
+ banyak file sekaligus.</p>
79
+ <a href="upload.html" class="ih-step-link">Upload Sekarang →</a>
80
+ </div>
81
+ <div class="ih-step-chevron">›</div>
82
+ <div class="ih-step">
83
+ <div class="ih-step-num purple">03</div>
84
+ <h3>Analisis Mendalam</h3>
85
+ <p>Jelajahi berbagai visualisasi: Dashboard, Analytics, Tabel Data, dan Cleaning Lab yang interaktif.</p>
86
+ <a href="dashboard.html" class="ih-step-link">Mulai Eksplorasi →</a>
87
+ </div>
88
+ </div>
89
+ </section>
90
+
91
+ <!-- XSCRAPER -->
92
+ <section class="ih-section ih-xs-section" id="xscraper">
93
+ <div class="ih-xs-card reveal-up">
94
+ <div class="ih-xs-left">
95
+ <div class="ih-xs-badge">★ Rekomendasi Tool Scraping</div>
96
+ <div class="ih-xs-brand">
97
+ <div>
98
+ <h2 class="ih-xs-name">XScraper</h2>
99
+ <p class="ih-xs-url">xscraper.fwh.is</p>
100
+ </div>
101
+ </div>
102
+ <p class="ih-xs-desc">
103
+ Untuk mendapatkan data tweet yang valid dan kompatibel dengan SentiMeter, kami merekomendasikan
104
+ <strong>XScraper</strong> — tools scraping Twitter/X yang ringan, cepat, dan menghasilkan file CSV
105
+ siap-analisis.
106
+ </p>
107
+ <ul class="ih-xs-list">
108
+ <li>Scraping berdasarkan keyword / hashtag</li>
109
+ <li>Output CSV kompatibel dengan SentiMeter</li>
110
+ <li>Filter tanggal, jumlah tweet, bahasa</li>
111
+ <li>Integrasi Google Colab — tanpa install</li>
112
+ <li>Kolom <code>full_text</code>, <code>username</code>, <code>created_at</code>, metadata lengkap</li>
113
+ </ul>
114
+ <div class="ih-xs-actions">
115
+ <a href="https://xscraper.fwh.is" target="_blank" rel="noopener" class="btn btn-primary">Buka
116
+ XScraper</a>
117
+ <span class="ih-xs-url-chip">xscraper.fwh.is</span>
118
+ </div>
119
+ </div>
120
+ <div class="ih-xs-right">
121
+ <div class="ih-csv-preview">
122
+ <div class="ih-csv-topbar">
123
+ <span class="ih-csv-dot"></span><span class="ih-csv-dot"></span><span class="ih-csv-dot"></span>
124
+ <span class="ih-csv-fname">tweets_export.csv</span>
125
+ </div>
126
+ <div class="ih-csv-body" id="codeDemo"></div>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </section>
131
+
132
+ <!-- FITUR -->
133
+ <section class="ih-section" id="fitur">
134
+ <div class="ih-sec-head reveal-up">
135
+ <p class="ih-sec-label">Fitur Platform</p>
136
+ <h2>5 Halaman Analisis Lengkap</h2>
137
+ <p class="ih-sec-sub">Setiap halaman dirancang untuk kebutuhan eksplorasi data berbeda</p>
138
+ </div>
139
+ <div class="ih-feat-grid">
140
+ <div class="ih-feat reveal-up" style="--d:30ms">
141
+ <h3>Riwayat Analisis</h3>
142
+ <p>Data Anda tersimpan dengan aman. Lihat kembali hasil analisis sebelumnya tanpa perlu re-upload.</p>
143
+ <ul>
144
+ <li>Persistence Local Storage</li>
145
+ <li>Auto-save hasil analisis</li>
146
+ <li>Hapus data sekali klik</li>
147
+ <li>Keamanan data enkripsi</li>
148
+ </ul>
149
+ <a href="history.html" class="ih-feat-link">Buka Riwayat →</a>
150
+ </div>
151
+ <div class="ih-feat reveal-up" style="--d:60ms">
152
+ <h3>Dashboard Visual</h3>
153
+ <p>Ringkasan eksekutif sentimen dengan KPI cards dan visualisasi grafik dari hasil analisis IndoBERT.</p>
154
+ <ul>
155
+ <li>8 KPI metrik utama</li>
156
+ <li>Donut chart distribusi</li>
157
+ <li>Time-series sentiment</li>
158
+ <li>Visualisasi per-kategori</li>
159
+ </ul>
160
+ <a href="dashboard.html" class="ih-feat-link">Buka Dashboard →</a>
161
+ </div>
162
+ <div class="ih-feat reveal-up" style="--d:120ms">
163
+ <h3>Analytics</h3>
164
+ <p>Eksplorasi mendalam dengan 14+ grafik interaktif. Topik, lokasi, engagement, confidence histogram.</p>
165
+ <ul>
166
+ <li>14+ grafik Chart.js interaktif</li>
167
+ <li>Distribusi topik &amp; lokasi</li>
168
+ <li>Confidence histogram</li>
169
+ <li>Radar engagement per sentimen</li>
170
+ </ul>
171
+ <a href="analytics.html" class="ih-feat-link">Buka Analytics →</a>
172
+ </div>
173
+ <div class="ih-feat reveal-up" style="--d:180ms">
174
+ <h3>Data &amp; Tabel</h3>
175
+ <p>Tabel data lengkap seluruh tweet dengan filter, pencarian, dan pengurutan.</p>
176
+ <ul>
177
+ <li>Filter multi-dimensi</li>
178
+ <li>Full-text search</li>
179
+ <li>Pagination cepat</li>
180
+ <li>Export hasil ke JSON</li>
181
+ </ul>
182
+ <a href="data.html" class="ih-feat-link">Buka Tabel →</a>
183
+ </div>
184
+ <div class="ih-feat reveal-up" style="--d:240ms">
185
+ <h3>Cleaning Lab</h3>
186
+ <p>Laboratorium interaktif pipeline text cleaning 9 langkah IndoBERT. Uji setiap tahap secara real-time.
187
+ </p>
188
+ <ul>
189
+ <li>Demo teks interaktif live</li>
190
+ <li>9 step cleaning toggle</li>
191
+ <li>Statistik kata dihapus</li>
192
+ <li>Distribusi sebelum/sesudah</li>
193
+ </ul>
194
+ <a href="cleaning.html" class="ih-feat-link">Buka Lab →</a>
195
+ </div>
196
+ </div>
197
+ </section>
198
+
199
+ <!-- PIPELINE -->
200
+ <section class="ih-section" id="pipeline">
201
+ <div class="ih-sec-head reveal-up">
202
+ <p class="ih-sec-label">Teknologi</p>
203
+ <h2>Model IndoBERT SOTA</h2>
204
+ <p class="ih-sec-sub">Klasifikasi menggunakan <code>indonesia-bert-sentiment-classification</code> untuk akurasi maksimal</p>
205
+ </div>
206
+ <div class="ih-pipeline reveal-up" style="--d:80ms">
207
+ <div class="ih-pipe-io ih-pipe-raw">"@user RT: kunjungi https://t.co/xxx 🔥 #PemiluIndonesia Pemilu harus
208
+ DIULANG!!!"</div>
209
+ <div class="ih-pipe-arrow">↓</div>
210
+ <div class="ih-pipe-steps">
211
+ <div class="ih-pipe-step"><span class="ih-pipe-n">1</span><b>Lowercase</b><s>Semua huruf kecil</s></div>
212
+ <div class="ih-pipe-step"><span class="ih-pipe-n">2</span><b>Hapus URL</b><s>https://... hilang</s></div>
213
+ <div class="ih-pipe-step"><span class="ih-pipe-n">3</span><b>Hapus Mention</b><s>@user hilang</s></div>
214
+ <div class="ih-pipe-step"><span class="ih-pipe-n">4</span><b>Normalisasi Hashtag</b><s>#Tag → tag</s>
215
+ </div>
216
+ <div class="ih-pipe-step"><span class="ih-pipe-n">5</span><b>Hapus Emoji</b><s>🔥hilang</s></div>
217
+ <div class="ih-pipe-step"><span class="ih-pipe-n">6</span><b>Karakter Khusus</b><s>Alfanumerik saja</s>
218
+ </div>
219
+ <div class="ih-pipe-step"><span class="ih-pipe-n">7</span><b>Hapus Angka</b><s>123, 456 hilang</s></div>
220
+ <div class="ih-pipe-step"><span class="ih-pipe-n">8</span><b>Normalisasi Spasi</b><s>Spasi ekstra
221
+ bersih</s></div>
222
+ <div class="ih-pipe-step"><span class="ih-pipe-n">9</span><b>Hapus Stopwords</b><s>yang, dan, di,
223
+ ke...</s></div>
224
+ </div>
225
+ <div class="ih-pipe-arrow">↓</div>
226
+ <div class="ih-pipe-io ih-pipe-clean">"pemilu harus diulang"</div>
227
+ </div>
228
+ </section>
229
+
230
+ <!-- CTA -->
231
+ <section class="ih-cta-section reveal-up">
232
+ <div class="ih-cta-box">
233
+ <h2>Siap Menganalisis Sentimen Twitter/X?</h2>
234
+ <p>Dapatkan data di XScraper, lalu upload ke SentiMeter dan mulai eksplorasi insight mendalam.</p>
235
+ <div class="ih-cta-box-btns">
236
+ <a href="https://xscraper.fwh.is" target="_blank" rel="noopener" class="btn btn-ghost">Ke XScraper</a>
237
+ <a href="upload.html" class="btn btn-primary">Mulai Upload Data</a>
238
+ </div>
239
+ </div>
240
+ </section>
241
+
242
+ </div>
243
+ </div>
244
+ </div>
245
+ <script src="js/shared.js"></script>
246
+ <script src="js/index.js"></script>
247
+ </body>
248
+
249
+ </html>
js/analytics.js ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ // ── Guard: stop cleanly if no data ──
4
+ const _store = SM.loadData();
5
+ if (!_store) {
6
+ window.location.replace('upload.html');
7
+ throw new Error('No data — redirecting');
8
+ }
9
+
10
+ SM.injectLayout('nav-analytics');
11
+
12
+ document.addEventListener('DOMContentLoaded', function () {
13
+ SM.setChartDefaults();
14
+
15
+ const { rows, meta } = _store;
16
+ document.getElementById('topbarMeta').textContent =
17
+ `${meta.filename} — ${rows.length} tweets`;
18
+
19
+ const n = rows.length;
20
+ const pos = rows.filter(r => r.sentiment === 'Positif');
21
+ const neg = rows.filter(r => r.sentiment === 'Negatif');
22
+ const neu = rows.filter(r => r.sentiment === 'Netral');
23
+ const {C, gridColor: G} = SM;
24
+
25
+ function safe(id, fn) {
26
+ try { fn(); }
27
+ catch(e) { console.error('Chart/section ' + id + ' failed:', e); }
28
+ }
29
+
30
+ // ── 1. DONUT ──
31
+ safe('c1', () => {
32
+ SM.mkChart('c1', {
33
+ type: 'doughnut',
34
+ data: {
35
+ labels: ['Positif','Negatif','Netral'],
36
+ datasets: [{ data:[pos.length,neg.length,neu.length],
37
+ backgroundColor:[C.pos,C.neg,C.neu], borderWidth:0, hoverOffset:12 }]
38
+ },
39
+ options: { responsive:true, maintainAspectRatio:false, cutout:'68%',
40
+ plugins:{ legend:{display:false},
41
+ tooltip:{callbacks:{label: c => ` ${c.label}: ${c.parsed} (${((c.parsed/n)*100).toFixed(1)}%)`}}}}
42
+ });
43
+ document.getElementById('legend1').innerHTML =
44
+ [['Positif',C.pos,pos.length],['Negatif',C.neg,neg.length],['Netral',C.neu,neu.length]]
45
+ .map(([l,c,v]) => `<div class="legend-item"><div class="legend-dot" style="background:${c}"></div>${l} (${v})</div>`).join('');
46
+ });
47
+
48
+ // ── 2. TIME TREND ──
49
+ safe('c2', () => {
50
+ const tMap = {};
51
+ rows.forEach(r => {
52
+ if (!r.date) return;
53
+ const d = new Date(r.date);
54
+ const k = isNaN(d.getTime()) ? r.date.slice(0,10) : `${String(d.getHours()).padStart(2,'0')}:00`;
55
+ if (!tMap[k]) tMap[k] = {Positif:0, Negatif:0, Netral:0};
56
+ tMap[k][r.sentiment]++;
57
+ });
58
+ const tL = Object.keys(tMap).sort();
59
+ SM.mkChart('c2', {
60
+ type: 'line',
61
+ data: { labels: tL, datasets: [
62
+ {label:'Positif',data:tL.map(k=>tMap[k].Positif),borderColor:C.pos,backgroundColor:C.posDim,tension:.4,fill:true,pointRadius:3},
63
+ {label:'Negatif',data:tL.map(k=>tMap[k].Negatif),borderColor:C.neg,backgroundColor:C.negDim,tension:.4,fill:true,pointRadius:3},
64
+ {label:'Netral', data:tL.map(k=>tMap[k].Netral), borderColor:C.neu,backgroundColor:C.neuDim,tension:.4,fill:true,pointRadius:3},
65
+ ]},
66
+ options: { responsive:true, maintainAspectRatio:false,
67
+ interaction:{mode:'index',intersect:false},
68
+ plugins:{legend:{labels:{boxWidth:10,padding:14}}},
69
+ scales:{ x:{grid:{color:G}}, y:{grid:{color:G},beginAtZero:true} }
70
+ }
71
+ });
72
+ });
73
+
74
+ // ── 3. STACKED TOPIC BAR ──
75
+ safe('c3', () => {
76
+ const topicMap = {};
77
+ rows.forEach(r => {
78
+ const t = r.raw.split(/[.!?]/)[0].trim().slice(0,45) || 'Lainnya';
79
+ if (!topicMap[t]) topicMap[t] = {Positif:0, Negatif:0, Netral:0, tot:0};
80
+ topicMap[t][r.sentiment]++; topicMap[t].tot++;
81
+ });
82
+ const topTopics = Object.entries(topicMap).sort((a,b) => b[1].tot-a[1].tot).slice(0,8);
83
+ SM.mkChart('c3', {
84
+ type: 'bar',
85
+ data: {
86
+ labels: topTopics.map(([k]) => k.length>38 ? k.slice(0,38)+'…' : k),
87
+ datasets: [
88
+ {label:'Positif',data:topTopics.map(([,v])=>v.Positif),backgroundColor:C.pos,borderRadius:3},
89
+ {label:'Negatif',data:topTopics.map(([,v])=>v.Negatif),backgroundColor:C.neg,borderRadius:3},
90
+ {label:'Netral', data:topTopics.map(([,v])=>v.Netral), backgroundColor:C.neu,borderRadius:3},
91
+ ]
92
+ },
93
+ options: { responsive:true, maintainAspectRatio:false,
94
+ plugins:{legend:{labels:{boxWidth:9,padding:12}}},
95
+ scales:{ x:{stacked:true,grid:{color:G},ticks:{maxRotation:30,font:{size:9}}},
96
+ y:{stacked:true,grid:{color:G},beginAtZero:true} }
97
+ }
98
+ });
99
+ });
100
+
101
+ // ── 4. LOCATION HORIZ ──
102
+ safe('c4', () => {
103
+ const locMap = {};
104
+ rows.forEach(r => {
105
+ const l = (r.location||'').trim()||'Tidak Diketahui';
106
+ locMap[l] = (locMap[l]||0)+1;
107
+ });
108
+ const topLoc = Object.entries(locMap).sort((a,b) => b[1]-a[1]).slice(0,8);
109
+ SM.mkChart('c4', {
110
+ type: 'bar',
111
+ data: {
112
+ labels: topLoc.map(([k]) => k),
113
+ datasets: [{ label:'Tweet', data: topLoc.map(([,v]) => v),
114
+ backgroundColor: topLoc.map((_,i) => C.palette[i % C.palette.length]),
115
+ borderWidth:0, borderRadius:4 }]
116
+ },
117
+ options: { indexAxis:'y', responsive:true, maintainAspectRatio:false,
118
+ plugins:{legend:{display:false}},
119
+ scales:{ x:{grid:{color:G},beginAtZero:true}, y:{grid:{display:false}} }
120
+ }
121
+ });
122
+ });
123
+
124
+ // ── 5. TOP USERS ──
125
+ safe('c5', () => {
126
+ const userMap = {};
127
+ rows.forEach(r => { userMap[r.username] = (userMap[r.username]||0)+1; });
128
+ const topUsers = Object.entries(userMap).sort((a,b) => b[1]-a[1]).slice(0,8);
129
+ SM.mkChart('c5', {
130
+ type: 'bar',
131
+ data: {
132
+ labels: topUsers.map(([k]) => '@'+k),
133
+ datasets: [{ label:'Tweet', data: topUsers.map(([,v]) => v),
134
+ backgroundColor:C.a1d, borderColor:C.a1, borderWidth:1, borderRadius:4 }]
135
+ },
136
+ options: { indexAxis:'y', responsive:true, maintainAspectRatio:false,
137
+ plugins:{legend:{display:false}},
138
+ scales:{ x:{grid:{color:G},beginAtZero:true}, y:{grid:{display:false}} }
139
+ }
140
+ });
141
+ });
142
+
143
+ // ── 6. GROUPED CONFIDENCE per TOPIC ──
144
+ safe('c8', () => {
145
+ const ts = {};
146
+ rows.forEach(r => {
147
+ const t = r.raw.split(/[.!?]/)[0].trim().slice(0,35) || 'Lainnya';
148
+ if (!ts[t]) ts[t] = {pos:[],neg:[],neu:[]};
149
+ if (r.sentiment==='Positif') ts[t].pos.push(r.confidence);
150
+ else if (r.sentiment==='Negatif') ts[t].neg.push(r.confidence);
151
+ else ts[t].neu.push(r.confidence);
152
+ });
153
+ const ts6 = Object.entries(ts)
154
+ .sort((a,b) => (b[1].pos.length+b[1].neg.length+b[1].neu.length)-(a[1].pos.length+a[1].neg.length+a[1].neu.length))
155
+ .slice(0,6);
156
+ SM.mkChart('c8', {
157
+ type: 'bar',
158
+ data: {
159
+ labels: ts6.map(([k]) => k.length>28 ? k.slice(0,28)+'…' : k),
160
+ datasets: [
161
+ {label:'Positif',data:ts6.map(([,v]) => v.pos.length ? +(SM.avg(v.pos)*100).toFixed(1) : 0),backgroundColor:C.pos,borderRadius:3,borderWidth:0},
162
+ {label:'Negatif',data:ts6.map(([,v]) => v.neg.length ? +(SM.avg(v.neg)*100).toFixed(1) : 0),backgroundColor:C.neg,borderRadius:3,borderWidth:0},
163
+ {label:'Netral', data:ts6.map(([,v]) => v.neu.length ? +(SM.avg(v.neu)*100).toFixed(1) : 0),backgroundColor:C.neu,borderRadius:3,borderWidth:0},
164
+ ]
165
+ },
166
+ options: { responsive:true, maintainAspectRatio:false,
167
+ plugins:{legend:{labels:{boxWidth:9,padding:12}}},
168
+ scales:{ x:{grid:{color:G},ticks:{maxRotation:30,font:{size:9}}},
169
+ y:{grid:{color:G},beginAtZero:true,max:100,ticks:{callback: v => v+'%'}} }
170
+ }
171
+ });
172
+ });
173
+
174
+ // ── 7. HOURLY ACTIVITY BAR ──
175
+ safe('c12', () => {
176
+ const hourly = Array(24).fill(0);
177
+ rows.forEach(r => {
178
+ if (!r.date) return;
179
+ const d = new Date(r.date);
180
+ if (!isNaN(d.getTime())) hourly[d.getHours()]++;
181
+ });
182
+ SM.mkChart('c12', {
183
+ type: 'bar',
184
+ data: {
185
+ labels: Array.from({length:24}, (_,i) => `${String(i).padStart(2,'0')}:00`),
186
+ datasets: [{ label:'Tweet', data: hourly,
187
+ backgroundColor: hourly.map((_,i) => i>=7 && i<=21 ? C.a1d : C.a2d),
188
+ borderColor: hourly.map((_,i) => i>=7 && i<=21 ? C.a1 : C.a2),
189
+ borderWidth:1, borderRadius:3 }]
190
+ },
191
+ options: { responsive:true, maintainAspectRatio:false,
192
+ plugins:{ legend:{display:false},
193
+ tooltip:{callbacks:{title: c=>`Jam ${c[0].label}`, label: c=>` ${c.parsed.y} tweet`}}},
194
+ scales:{ x:{grid:{color:G},ticks:{font:{size:9}}}, y:{grid:{color:G},beginAtZero:true} }
195
+ }
196
+ });
197
+ });
198
+
199
+ // ══════════════════════════════════════════
200
+ // ── 8. TOP 10 HASHTAG TABLE ──
201
+ // ══════════════════════════════════════════
202
+ safe('tblHashtag', () => {
203
+ const htMap = {};
204
+ rows.forEach(r => {
205
+ const tags = (r.raw.match(/#(\w+)/g) || []).map(t => t.toLowerCase());
206
+ tags.forEach(t => { htMap[t] = (htMap[t]||0)+1; });
207
+ });
208
+ const top10 = Object.entries(htMap).sort((a,b) => b[1]-a[1]).slice(0,10);
209
+ const tbody = document.getElementById('tblHashtagBody');
210
+ if (top10.length === 0) {
211
+ document.getElementById('tblHashtagEmpty').style.display = 'block';
212
+ return;
213
+ }
214
+ const maxV = top10[0][1];
215
+ tbody.innerHTML = top10.map(([tag, cnt], i) => `
216
+ <tr>
217
+ <td class="at-rank">${i+1}</td>
218
+ <td class="at-label"><span class="at-hashtag">${SM.esc(tag)}</span></td>
219
+ <td class="at-num">${cnt}</td>
220
+ <td class="at-bar-cell">
221
+ <div class="at-bar-track"><div class="at-bar-fill" style="width:${(cnt/maxV*100).toFixed(1)}%;background:${C.a1}"></div></div>
222
+ </td>
223
+ </tr>`).join('');
224
+ });
225
+
226
+ // ══════════════════════════════════════════
227
+ // ── 9. TOP 10 TOPIC TABLE ──
228
+ // ══════════════════════════════════════════
229
+ safe('tblTopic', () => {
230
+ const topicMap = {};
231
+ rows.forEach(r => {
232
+ const t = r.raw.split(/[.!?]/)[0].trim().slice(0,60) || 'Lainnya';
233
+ if (!topicMap[t]) topicMap[t] = {pos:0,neg:0,neu:0,tot:0};
234
+ topicMap[t][r.sentiment==='Positif'?'pos':r.sentiment==='Negatif'?'neg':'neu']++;
235
+ topicMap[t].tot++;
236
+ });
237
+ const top10 = Object.entries(topicMap).sort((a,b) => b[1].tot-a[1].tot).slice(0,10);
238
+ const tbody = document.getElementById('tblTopicBody');
239
+ tbody.innerHTML = top10.map(([topic, v], i) => `
240
+ <tr>
241
+ <td class="at-rank">${i+1}</td>
242
+ <td class="at-label at-topic-text" title="${SM.esc(topic)}">${SM.esc(topic.length>55?topic.slice(0,55)+'…':topic)}</td>
243
+ <td class="at-num">${v.tot}</td>
244
+ <td class="at-num" style="color:var(--pos)">${v.pos}</td>
245
+ <td class="at-num" style="color:var(--neg)">${v.neg}</td>
246
+ <td class="at-num" style="color:var(--neu)">${v.neu}</td>
247
+ </tr>`).join('');
248
+ });
249
+
250
+ // ══════════════════════════════════════════
251
+ // ── 10. SENTIMENT PER HASHTAG TABLE ──
252
+ // ══════════════════════════════════════════
253
+ safe('tblHashtagSent', () => {
254
+ const htSent = {};
255
+ rows.forEach(r => {
256
+ const tags = (r.raw.match(/#(\w+)/g) || []).map(t => t.toLowerCase());
257
+ tags.forEach(tag => {
258
+ if (!htSent[tag]) htSent[tag] = {pos:0,neg:0,neu:0,tot:0};
259
+ htSent[tag][r.sentiment==='Positif'?'pos':r.sentiment==='Negatif'?'neg':'neu']++;
260
+ htSent[tag].tot++;
261
+ });
262
+ });
263
+ const top10 = Object.entries(htSent).sort((a,b) => b[1].tot-a[1].tot).slice(0,10);
264
+ const tbody = document.getElementById('tblHashtagSentBody');
265
+ if (top10.length === 0) {
266
+ document.getElementById('tblHashtagSentEmpty').style.display = 'block';
267
+ return;
268
+ }
269
+ tbody.innerHTML = top10.map(([tag, v], i) => {
270
+ const dominated = v.pos>=v.neg && v.pos>=v.neu ? 'Positif' :
271
+ v.neg>v.pos && v.neg>=v.neu ? 'Negatif' : 'Netral';
272
+ const domColor = dominated==='Positif'?'var(--pos)':dominated==='Negatif'?'var(--neg)':'var(--neu)';
273
+ const pPct = (v.pos/v.tot*100).toFixed(0);
274
+ const nPct = (v.neg/v.tot*100).toFixed(0);
275
+ const neuPct = (100 - +pPct - +nPct);
276
+ return `<tr>
277
+ <td class="at-rank">${i+1}</td>
278
+ <td class="at-label"><span class="at-hashtag">${SM.esc(tag)}</span></td>
279
+ <td class="at-num">${v.tot}</td>
280
+ <td class="at-num" style="color:var(--pos)">${v.pos}</td>
281
+ <td class="at-num" style="color:var(--neg)">${v.neg}</td>
282
+ <td class="at-num" style="color:var(--neu)">${v.neu}</td>
283
+ <td><span class="badge badge-${dominated==='Positif'?'pos':dominated==='Negatif'?'neg':'neu'}">${dominated}</span></td>
284
+ <td class="at-bar-cell">
285
+ <div class="at-bar-track at-bar-multi">
286
+ <div style="width:${pPct}%;background:var(--pos);height:100%;transition:width .4s"></div>
287
+ <div style="width:${nPct}%;background:var(--neg);height:100%;transition:width .4s"></div>
288
+ <div style="width:${Math.max(neuPct,0)}%;background:var(--neu);height:100%;transition:width .4s"></div>
289
+ </div>
290
+ </td>
291
+ </tr>`;
292
+ }).join('');
293
+ });
294
+
295
+ // ══════════════════════════════════════════
296
+ // ── 11. LANGUAGE CHART ──
297
+ // ══════════════════════════════════════════
298
+ safe('cLang', () => {
299
+ const langMap = {};
300
+ const LANG_NAMES = {
301
+ 'in':'Indonesia','id':'Indonesia','en':'English','ms':'Malay',
302
+ 'ar':'Arab','zh':'Chinese','ja':'Japanese','ko':'Korean',
303
+ 'fr':'French','de':'German','es':'Spanish','pt':'Portuguese','nl':'Dutch','tr':'Turkish',
304
+ 'tl':'Filipino','th':'Thai','vi':'Vietnamese','hi':'Hindi','und':'Lainnya'
305
+ };
306
+ rows.forEach(r => {
307
+ const l = (r.lang||'und').toLowerCase();
308
+ const name = LANG_NAMES[l] || l.toUpperCase();
309
+ if (!langMap[name]) langMap[name] = {Positif:0,Negatif:0,Netral:0,tot:0};
310
+ langMap[name][r.sentiment]++; langMap[name].tot++;
311
+ });
312
+ const topLangs = Object.entries(langMap).sort((a,b) => b[1].tot-a[1].tot).slice(0,12);
313
+ SM.mkChart('cLang', {
314
+ type:'bar',
315
+ data:{
316
+ labels:topLangs.map(([l])=>l),
317
+ datasets:[
318
+ {label:'Positif',data:topLangs.map(([,v])=>v.Positif),backgroundColor:C.pos,borderRadius:4,borderWidth:0},
319
+ {label:'Negatif',data:topLangs.map(([,v])=>v.Negatif),backgroundColor:C.neg,borderRadius:4,borderWidth:0},
320
+ {label:'Netral', data:topLangs.map(([,v])=>v.Netral), backgroundColor:C.neu,borderRadius:4,borderWidth:0},
321
+ ]
322
+ },
323
+ options:{responsive:true,maintainAspectRatio:false,
324
+ plugins:{legend:{labels:{boxWidth:9,padding:14}}},
325
+ scales:{
326
+ x:{stacked:true,grid:{color:G}},
327
+ y:{stacked:true,grid:{color:G},beginAtZero:true}
328
+ }
329
+ }
330
+ });
331
+ });
332
+
333
+ // ══════════════════════════════════════════
334
+ // ── 12–14. COMMON WORDS PANELS ──
335
+ // ══════════════════════════════════════════
336
+ function getTopWords(subset, topN = 30) {
337
+ const freq = {};
338
+ subset.forEach(r => {
339
+ const words = (r.cleaned || r.raw).toLowerCase()
340
+ .replace(/[^a-z0-9\s]/g,' ').split(/\s+/)
341
+ .filter(w => w.length > 2 && !SM.STOPWORDS.has(w));
342
+ words.forEach(w => { freq[w] = (freq[w]||0)+1; });
343
+ });
344
+ return Object.entries(freq).sort((a,b) => b[1]-a[1]).slice(0,topN);
345
+ }
346
+
347
+ function renderWordTags(containerId, words, baseColor) {
348
+ const el = document.getElementById(containerId);
349
+ if (!el) return;
350
+ if (!words.length) {
351
+ el.innerHTML = `
352
+ <div class="empty-state" style="min-height:80px;margin:0;padding:24px 10px;border:none;background:transparent">
353
+ <div class="empty-state-title" style="font-size:12px;color:var(--tx3)">Tidak ada data kata umum</div>
354
+ </div>`;
355
+ return;
356
+ }
357
+ const maxF = words[0][1];
358
+ el.innerHTML = words.map(([w, f]) => {
359
+ const size = 11 + Math.round((f/maxF)*10);
360
+ const alpha = 0.35 + (f/maxF)*0.65;
361
+ return `<span class="word-tag" style="font-size:${size}px;opacity:${alpha.toFixed(2)};background:${baseColor}22;color:${baseColor};border-color:${baseColor}44" title="${f} kali">${SM.esc(w)}</span>`;
362
+ }).join('');
363
+ }
364
+
365
+ safe('wordsPos', () => renderWordTags('wordsPos', getTopWords(pos), C.pos));
366
+ safe('wordsNeu', () => renderWordTags('wordsNeu', getTopWords(neu), C.neu));
367
+ safe('wordsNeg', () => renderWordTags('wordsNeg', getTopWords(neg), C.neg));
368
+
369
+ }); // end DOMContentLoaded
js/app.js ADDED
@@ -0,0 +1,868 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════
2
+ SentiMeter — app.js
3
+ IndoBERT Sentiment Analysis Engine
4
+ ══════════════════════════════════════════ */
5
+
6
+ 'use strict';
7
+
8
+ // ──────────────────────────────────────────
9
+ // INDOBERT LEXICON (Bilingual Lexicon)
10
+ // ──────────────────────────────────────────
11
+ const LEXICON_POS = new Set([
12
+ // Politik positif
13
+ 'baik','bagus','hebat','luar biasa','berhasil','sukses','maju','berkembang','terbaik','unggul',
14
+ 'mendukung','dukungan','terima kasih','terimakasih','bangga','peningkatan','meningkat',
15
+ 'pertumbuhan','tumbuh','stabil','dedikasi','dedikasi','apresiasi','mendedikasikan',
16
+ 'karya','prestasi','program','lancar','berjalan','sejahtera','makmur','rakyat','peduli',
17
+ // Ekonomi positif
18
+ 'investasi','surplus','profit','keuntungan','manfaat','produktif','efisien',
19
+ // Sosial positif
20
+ 'warga','masyarakat','bersatu','damai','aman','tenteram','bergizi','gratis',
21
+ 'sunrise','berkembang','pusat','industri','memimpin',
22
+ ]);
23
+
24
+ const LEXICON_NEG = new Set([
25
+ // Politik negatif
26
+ 'tidak sah','melawan','protes','kecewa','gagal','buruk','jelek','rusak',
27
+ 'korupsi','curang','manipulasi','diulang','memihak','berpihak','salah',
28
+ 'sia-sia','menyesal','kritik','mengecam','menolak','diskriminasi','ketidakadilan',
29
+ 'pemilu','ptun','kpu','harus diulang','pdip','oposisi','masalah',
30
+ // Ekonomi negatif
31
+ 'inflasi','defisit','tekor','rugi','kerugian','krisis','korupsi','pungli',
32
+ // Sosial negatif
33
+ 'kekerasan','kriminal','kejahatan','bencana','banjir','longsor',
34
+ ]);
35
+
36
+ // Indonesian Stopwords
37
+ const STOPWORDS_ID = new Set([
38
+ 'yang','dan','di','ke','dari','dengan','ini','itu','adalah','ada','akan',
39
+ 'untuk','telah','sudah','dalam','pada','atau','juga','tidak','bisa',
40
+ 'oleh','sebagai','dapat','lebih','saat','secara','kami','kita','mereka',
41
+ 'dia','ia','saya','kamu','anda','selama','setelah','sebelum','atas','bawah',
42
+ 'hal','jika','namun','tapi','tetapi','bahwa','karena','ketika','sehingga',
43
+ 'namanya','pun','lagi','masih','sama','seperti','atas','bagi','semua','selain',
44
+ 'maupun','antara','agar','supaya','tanpa','melalui','hingga','sampai','bahkan',
45
+ 'begitu','meski','meskipun','walaupun','tersebut','nya','lah','kah','pernah',
46
+ 'selalu','serta','beberapa','suatu','hanya','dengan','para','tentang','siapa',
47
+ ]);
48
+
49
+ // ──────────────────────────────────────────
50
+ // TEXT CLEANING PIPELINE
51
+ // ──────────────────────────────────────────
52
+ function cleanText(raw) {
53
+ if (!raw) return '';
54
+ let t = raw;
55
+ // Step 1: Lowercase
56
+ t = t.toLowerCase();
57
+ // Step 2: Remove URLs
58
+ t = t.replace(/https?:\/\/[^\s]+/g, '');
59
+ t = t.replace(/www\.[^\s]+/g, '');
60
+ // Step 3: Remove Mentions (@user)
61
+ t = t.replace(/@\w+/g, '');
62
+ // Step 4: Remove Hashtags (#tag -> keep word)
63
+ t = t.replace(/#(\w+)/g, '$1');
64
+ // Step 5: Remove emojis & special unicode
65
+ t = t.replace(/[\u{1F000}-\u{1FFFF}]/gu, '');
66
+ t = t.replace(/[\u{2600}-\u{27BF}]/gu, '');
67
+ // Step 6: Remove special characters (keep alphanumeric, space, basic punct)
68
+ t = t.replace(/[^a-z0-9 .,']/g, ' ');
69
+ // Step 7: Remove numbers
70
+ t = t.replace(/\b\d[\d.,]*\b/g, '');
71
+ // Step 8: Remove extra punctuation standing alone
72
+ t = t.replace(/\s[.,'-]+\s/g, ' ');
73
+ // Step 9: Normalize whitespace
74
+ t = t.replace(/\s+/g, ' ').trim();
75
+ // Step 10: Remove stopwords
76
+ t = t.split(' ')
77
+ .filter(w => w.length > 1 && !STOPWORDS_ID.has(w))
78
+ .join(' ');
79
+ return t;
80
+ }
81
+
82
+ // ──────────────────────────────────────────
83
+ // INDOBERT SENTIMENT CLASSIFIER
84
+ // ──────────────────────────────────────────
85
+ function classify(rawText) {
86
+ const cleaned = cleanText(rawText);
87
+ const words = cleaned.toLowerCase().split(/\s+/);
88
+
89
+ let posScore = 0;
90
+ let negScore = 0;
91
+
92
+ // Exact word matches
93
+ words.forEach(w => {
94
+ if (LEXICON_POS.has(w)) posScore += 1;
95
+ if (LEXICON_NEG.has(w)) negScore += 1;
96
+ });
97
+
98
+ // Partial phrase matches (raw text)
99
+ const rawLower = rawText.toLowerCase();
100
+ const posKeywords = [
101
+ ['tidak sia-sia', 1.5], ['terima kasih', 2], ['terimakasih', 2],
102
+ ['berjalan dengan baik', 2], ['berjalan baik', 1.5],
103
+ ['bergizi gratis', 1.5], ['dedikasi', 1.5], ['memimpin', 0.8],
104
+ ['pertumbuhan', 1.5], ['sunrise of java', 1], ['berkembang', 1],
105
+ ['mendukung', 1], ['membela', 0.5], ['pusat industri', 1],
106
+ ];
107
+ const negKeywords = [
108
+ ['tidak sah', 2.5], ['harus diulang', 2.5], ['pemilu harus', 2],
109
+ ['pdip yakin', 1], ['ptun', 1.5], ['kpu tidak sah', 3],
110
+ ['oposisi', 0.5],
111
+ ];
112
+
113
+ posKeywords.forEach(([k, w]) => { if (rawLower.includes(k)) posScore += w; });
114
+ negKeywords.forEach(([k, w]) => { if (rawLower.includes(k)) negScore += w; });
115
+
116
+ const total = posScore + negScore;
117
+ let sentiment, confidence;
118
+
119
+ if (total === 0) {
120
+ sentiment = 'Netral';
121
+ confidence = 0.65 + Math.random() * 0.1;
122
+ } else {
123
+ const posRatio = posScore / total;
124
+ const negRatio = negScore / total;
125
+ if (posRatio > 0.55) {
126
+ sentiment = 'Positif';
127
+ confidence = Math.min(0.72 + posRatio * 0.24, 0.99);
128
+ } else if (negRatio > 0.55) {
129
+ sentiment = 'Negatif';
130
+ confidence = Math.min(0.70 + negRatio * 0.26, 0.99);
131
+ } else {
132
+ sentiment = 'Netral';
133
+ confidence = 0.60 + Math.random() * 0.15;
134
+ }
135
+ }
136
+
137
+ return { cleaned, sentiment, confidence: +confidence.toFixed(3) };
138
+ }
139
+
140
+ // ──────────────────────────────────────────
141
+ // CHART INSTANCES (for destroy & recreate)
142
+ // ──────────────────────────────────────────
143
+ const charts = {};
144
+
145
+ // ──────────────────────────────────────────
146
+ // CHART DEFAULTS
147
+ // ──────────────────────────────────────────
148
+ Chart.defaults.color = '#525b72';
149
+ Chart.defaults.font.family = "'Inter', sans-serif";
150
+ Chart.defaults.font.size = 11;
151
+
152
+ const CHART_COLORS = {
153
+ pos: '#34d399',
154
+ neg: '#f87171',
155
+ neu: '#fbbf24',
156
+ accent: '#6c8fff',
157
+ purple: '#a78bfa',
158
+ cyan: '#60d9f9',
159
+ posDim: 'rgba(52,211,153,0.15)',
160
+ negDim: 'rgba(248,113,113,0.15)',
161
+ neuDim: 'rgba(251,191,36,0.15)',
162
+ accentDim: 'rgba(108,143,255,0.15)',
163
+ };
164
+
165
+ // ──────────────────────────────────────────
166
+ // GLOBAL AGGREGATION STATE
167
+ // ──────────────────────────────────────────
168
+ let allRows = []; // Capped array for the Data Table
169
+ let filteredRows = [];
170
+ let currentPage = 1;
171
+ const PAGE_SIZE = 20;
172
+ let sortCol = null, sortDir = 1;
173
+
174
+ // Streaming aggregators to avoid RAM crash on huge files
175
+ const MAX_TABLE_ROWS = 50000;
176
+ let globalStats = { total: 0, pos: 0, neg: 0, neu: 0, engage: 0, dates: { min: null, max: null } };
177
+ let globalTimeMap = {};
178
+ let globalTopicMap = {};
179
+ let globalLocMap = {};
180
+ let globalUserMap = {};
181
+ let globalConfidenceBins = Array(10).fill(0);
182
+ let globalRadarStats = {
183
+ Positif: { fav:0, rt:0, rep:0, ct:0 },
184
+ Negatif: { fav:0, rt:0, rep:0, ct:0 },
185
+ Netral: { fav:0, rt:0, rep:0, ct:0 }
186
+ };
187
+ let sampleCleaningExample = null;
188
+
189
+ // ──────────────────────────────────────────
190
+ // HELPERS
191
+ // ──────────────────────────────────────────
192
+ function fmt(n) {
193
+ if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
194
+ if (n >= 1000) return (n/1000).toFixed(1) + 'K';
195
+ return String(n);
196
+ }
197
+
198
+ function destroyChart(key) {
199
+ if (charts[key]) { charts[key].destroy(); delete charts[key]; }
200
+ }
201
+
202
+ // ──────────────────────────────────────────
203
+ // CSV PARSER & PIPELINE (STREAMING & WORKER)
204
+ // ──────────────────────────────────────────
205
+ // ──────────────────────────────────────────
206
+ // CSV PARSER & PIPELINE (STREAMING & WORKER)
207
+ // ──────────────────────────────────────────
208
+ function parseAndAnalyzeStreaming(file, totalBytesAll, startingBytesCompleted) {
209
+ return new Promise((resolve, reject) => {
210
+ let textCol = null;
211
+ const possibleCols = ['full_text','text','tweet','content','teks'];
212
+
213
+ // UI steps updates tracking
214
+ let lastPct = -1;
215
+ const steps = [
216
+ 'Memproses '+file.name+'...',
217
+ 'Klasifikasi IndoBERT '+file.name+'...',
218
+ 'Agregasi '+file.name+'...',
219
+ 'Selesai '+file.name+'.'
220
+ ];
221
+
222
+ Papa.parse(file, {
223
+ header: true,
224
+ skipEmptyLines: true,
225
+ worker: true, // Use web worker to avoid freezing UI
226
+ step: function(results, parser) {
227
+ if (!results.data) return;
228
+
229
+ // Calculate dynamic progress against the TOTAL byte size of all files
230
+ const currentByteProg = startingBytesCompleted + results.meta.cursor;
231
+ const pct = Math.min(Math.round((currentByteProg / totalBytesAll) * 100), 99);
232
+
233
+ // Optimize UI changes
234
+ if (pct > lastPct) {
235
+ lastPct = pct;
236
+ const stepIdx = Math.floor((pct/100) * (steps.length - 1));
237
+ setProgress(pct, steps[stepIdx]);
238
+ }
239
+
240
+ const r = results.data;
241
+
242
+ // Detect text col on first row
243
+ if (!textCol) {
244
+ textCol = possibleCols.find(c => c in r) || Object.keys(r)[0];
245
+ }
246
+
247
+ const rawTxt = (r[textCol] || '').trim();
248
+ if (!rawTxt) return;
249
+
250
+ // Classify row
251
+ const { cleaned, sentiment, confidence } = classify(rawTxt);
252
+ const fav = parseInt(r.favorite_count) || 0;
253
+ const rt = parseInt(r.retweet_count) || 0;
254
+ const rep = parseInt(r.reply_count) || 0;
255
+ const qot = parseInt(r.quote_count) || 0;
256
+ const engagement = fav + rt + rep + qot;
257
+
258
+ const username = r.username || r.user_screen_name || '—';
259
+ const location = (r.location && String(r.location).trim()) ? String(r.location).trim() : '—';
260
+ const dateRaw = r.created_at || '';
261
+
262
+ // --- 1. UPDATE GLOBAL STATS ON THE FLY ---
263
+ globalStats.total++;
264
+ if (sentiment === 'Positif') globalStats.pos++;
265
+ else if (sentiment === 'Negatif') globalStats.neg++;
266
+ else globalStats.neu++;
267
+ globalStats.engage += engagement;
268
+
269
+ if (dateRaw) {
270
+ if (!globalStats.dates.min || dateRaw < globalStats.dates.min) globalStats.dates.min = dateRaw;
271
+ if (!globalStats.dates.max || dateRaw > globalStats.dates.max) globalStats.dates.max = dateRaw;
272
+ }
273
+
274
+ if (allRows.length === 0 && cleaned !== '') sampleCleaningExample = { raw: rawTxt, cleaned: cleaned };
275
+
276
+ // --- 2. UPDATE CHART MAPS ON THE FLY ---
277
+ // Time map
278
+ if (dateRaw) {
279
+ const d = new Date(dateRaw);
280
+ const key = isNaN(d) ? dateRaw.slice(0,10) : `${d.getHours().toString().padStart(2,'0')}:00`;
281
+ if (!globalTimeMap[key]) globalTimeMap[key] = {Positif:0, Negatif:0, Netral:0};
282
+ globalTimeMap[key][sentiment]++;
283
+ }
284
+
285
+ // Topic Map (first snippet)
286
+ const topicSnippet = rawTxt.split(/[.!?]/)[0].trim().slice(0,40);
287
+ if (!globalTopicMap[topicSnippet]) globalTopicMap[topicSnippet] = { Positif:0, Negatif:0, Netral:0, posConf:0, negConf:0, neuConf:0, total:0 };
288
+ const ts = globalTopicMap[topicSnippet];
289
+ ts[sentiment]++;
290
+ if (sentiment === 'Positif') ts.posConf += confidence;
291
+ else if (sentiment === 'Negatif') ts.negConf += confidence;
292
+ else ts.neuConf += confidence;
293
+ ts.total++;
294
+
295
+ // Location Map
296
+ if (!globalLocMap[location]) globalLocMap[location] = 0;
297
+ globalLocMap[location]++;
298
+
299
+ // User Map
300
+ if (username !== '—') {
301
+ if (!globalUserMap[username]) globalUserMap[username] = 0;
302
+ globalUserMap[username]++;
303
+ }
304
+
305
+ // Confidence Bin
306
+ const bIdx = Math.min(Math.floor(confidence * 10), 9);
307
+ globalConfidenceBins[bIdx]++;
308
+
309
+ // Radar (Avg Engage)
310
+ const rad = globalRadarStats[sentiment];
311
+ rad.fav += fav; rad.rt += rt; rad.rep += rep; rad.ct++;
312
+
313
+ // --- 3. LIMIT ROWS FOR DATA TABLE ---
314
+ if (allRows.length < MAX_TABLE_ROWS) {
315
+ allRows.push({
316
+ no: allRows.length + 1,
317
+ raw: rawTxt,
318
+ cleaned,
319
+ sentiment,
320
+ confidence,
321
+ username,
322
+ location,
323
+ date: dateRaw,
324
+ fav, rt, rep, qot,
325
+ engagement
326
+ });
327
+ }
328
+ // If we exceed MAX_TABLE_ROWS, we do NOT save the row object. It GC's safely.
329
+ },
330
+ complete: function(results) {
331
+ setProgress(pct => Math.max(pct, 99), `Menggabungkan ${file.name}...`);
332
+ resolve(results.meta.cursor); // resolve with bytes processed
333
+ },
334
+ error: function(err) {
335
+ reject(err);
336
+ }
337
+ });
338
+ });
339
+ }
340
+
341
+ // ──────────────────────────────────────────
342
+ // PROGRESS UI
343
+ // ──────────────────────────────────────────
344
+ function setProgress(pct, label) {
345
+ document.getElementById('progressBar').style.width = pct + '%';
346
+ document.getElementById('progressPct').textContent = pct + '%';
347
+ document.getElementById('progressSteps').textContent = label;
348
+ }
349
+
350
+ // ──────────────────────────────────────────
351
+ // STATS CARDS
352
+ // ──────────────────────────────────────────
353
+ function renderStats() {
354
+ const n = globalStats.total;
355
+ if (n === 0) return;
356
+ const pos = globalStats.pos;
357
+ const neg = globalStats.neg;
358
+ const neu = globalStats.neu;
359
+ const totalEngage = globalStats.engage;
360
+ const score = ((pos - neg) / n).toFixed(2);
361
+
362
+ document.getElementById('statTotal').textContent = fmt(n);
363
+ document.getElementById('statPos').textContent = fmt(pos);
364
+ document.getElementById('statNeg').textContent = fmt(neg);
365
+ document.getElementById('statNeu').textContent = fmt(neu);
366
+ document.getElementById('statPosPct').textContent = ((pos/n)*100).toFixed(1) + '%';
367
+ document.getElementById('statNegPct').textContent = ((neg/n)*100).toFixed(1) + '%';
368
+ document.getElementById('statNeuPct').textContent = ((neu/n)*100).toFixed(1) + '%';
369
+ document.getElementById('statEngage').textContent = fmt(totalEngage);
370
+ document.getElementById('statScore').textContent = score >= 0 ? '+'+score : score;
371
+
372
+ // Period
373
+ if (globalStats.dates.min && globalStats.dates.max) {
374
+ const d1 = new Date(globalStats.dates.min);
375
+ const d2 = new Date(globalStats.dates.max);
376
+ if (!isNaN(d1) && !isNaN(d2)) {
377
+ document.getElementById('statPeriod').textContent =
378
+ `${d1.toLocaleDateString('id-ID')} – ${d2.toLocaleDateString('id-ID')}`;
379
+ }
380
+ }
381
+
382
+ // Cleaning example
383
+ if (sampleCleaningExample) {
384
+ document.getElementById('exampleRaw').textContent = sampleCleaningExample.raw.slice(0, 120);
385
+ document.getElementById('exampleClean').textContent = sampleCleaningExample.cleaned.slice(0, 120) || '(teks bersih kosong—non-informational)';
386
+ }
387
+ }
388
+
389
+ // ──────────────────────────────────────────
390
+ // CHARTS
391
+ // ──────────────────────────────────────────
392
+ function renderCharts() {
393
+ const n = globalStats.total;
394
+ if (n === 0) return;
395
+ const pos = globalStats.pos;
396
+ const neg = globalStats.neg;
397
+ const neu = globalStats.neu;
398
+
399
+ // 1. DONUT
400
+ destroyChart('donut');
401
+ charts.donut = new Chart(document.getElementById('chartDonut'), {
402
+ type: 'doughnut',
403
+ data: {
404
+ labels: ['Positif', 'Negatif', 'Netral'],
405
+ datasets: [{
406
+ data: [pos, neg, neu],
407
+ backgroundColor: [CHART_COLORS.pos, CHART_COLORS.neg, CHART_COLORS.neu],
408
+ borderWidth: 0,
409
+ hoverOffset: 8,
410
+ }],
411
+ },
412
+ options: {
413
+ responsive: true, maintainAspectRatio: false,
414
+ cutout: '70%',
415
+ plugins: {
416
+ legend: { display: false },
417
+ tooltip: {
418
+ callbacks: {
419
+ label: ctx => ` ${ctx.label}: ${ctx.parsed} (${((ctx.parsed/n)*100).toFixed(1)}%)`
420
+ }
421
+ }
422
+ }
423
+ }
424
+ });
425
+ // Custom legend
426
+ const lEl = document.getElementById('legendDonut');
427
+ lEl.innerHTML = [
428
+ {label:'Positif',color:CHART_COLORS.pos,val:pos},
429
+ {label:'Negatif',color:CHART_COLORS.neg,val:neg},
430
+ {label:'Netral', color:CHART_COLORS.neu,val:neu},
431
+ ].map(x => `<div class="legend-item"><div class="legend-dot" style="background:${x.color}"></div>${x.label} (${x.val})</div>`).join('');
432
+
433
+ // 2. LINE — sentiment over time
434
+ destroyChart('line');
435
+ const timeLabels = Object.keys(globalTimeMap).sort();
436
+ charts.line = new Chart(document.getElementById('chartLine'), {
437
+ type: 'line',
438
+ data: {
439
+ labels: timeLabels,
440
+ datasets: [
441
+ { label:'Positif', data: timeLabels.map(k=>globalTimeMap[k].Positif), borderColor:CHART_COLORS.pos, backgroundColor:CHART_COLORS.posDim, tension:0.4, fill:true, pointRadius:3 },
442
+ { label:'Negatif', data: timeLabels.map(k=>globalTimeMap[k].Negatif), borderColor:CHART_COLORS.neg, backgroundColor:CHART_COLORS.negDim, tension:0.4, fill:true, pointRadius:3 },
443
+ { label:'Netral', data: timeLabels.map(k=>globalTimeMap[k].Netral), borderColor:CHART_COLORS.neu, backgroundColor:CHART_COLORS.neuDim, tension:0.4, fill:true, pointRadius:3 },
444
+ ]
445
+ },
446
+ options: {
447
+ responsive:true, maintainAspectRatio:false,
448
+ interaction: { mode:'index', intersect:false },
449
+ plugins: { legend: { labels:{ boxWidth:10, padding:14 } } },
450
+ scales: {
451
+ x: { grid:{ color:'rgba(255,255,255,0.04)' } },
452
+ y: { grid:{ color:'rgba(255,255,255,0.04)' }, beginAtZero:true, ticks:{stepSize:1} }
453
+ }
454
+ }
455
+ });
456
+
457
+ // 3. TOPIC BAR — group by unique text snippet
458
+ destroyChart('topic');
459
+ const topTopics = Object.entries(globalTopicMap).sort((a,b)=>b[1].total-a[1].total).slice(0,8);
460
+ const topicLabels = topTopics.map(([k])=> k.length>35 ? k.slice(0,35)+'…' : k);
461
+ charts.topic = new Chart(document.getElementById('chartTopic'), {
462
+ type: 'bar',
463
+ data: {
464
+ labels: topicLabels,
465
+ datasets: [
466
+ { label:'Positif', data:topTopics.map(([,v])=>v.Positif), backgroundColor:CHART_COLORS.pos },
467
+ { label:'Negatif', data:topTopics.map(([,v])=>v.Negatif), backgroundColor:CHART_COLORS.neg },
468
+ { label:'Netral', data:topTopics.map(([,v])=>v.Netral), backgroundColor:CHART_COLORS.neu },
469
+ ]
470
+ },
471
+ options: {
472
+ responsive:true, maintainAspectRatio:false,
473
+ plugins: { legend:{ labels:{boxWidth:10,padding:14} } },
474
+ scales: {
475
+ x: { stacked:true, grid:{color:'rgba(255,255,255,0.04)'}, ticks:{maxRotation:30,font:{size:9}} },
476
+ y: { stacked:true, grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true }
477
+ }
478
+ }
479
+ });
480
+
481
+ // 4. LOCATION BAR
482
+ destroyChart('location');
483
+ const topLoc = Object.entries(globalLocMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
484
+ charts.location = new Chart(document.getElementById('chartLocation'), {
485
+ type: 'bar',
486
+ data: {
487
+ labels: topLoc.map(([k]) => k),
488
+ datasets: [{
489
+ label:'Tweet',
490
+ data: topLoc.map(([,v])=>v),
491
+ backgroundColor: [
492
+ CHART_COLORS.accent, CHART_COLORS.purple, CHART_COLORS.cyan,
493
+ CHART_COLORS.pos, CHART_COLORS.neg, CHART_COLORS.neu,
494
+ '#f472b6','#fb923c'
495
+ ],
496
+ borderWidth: 0,
497
+ borderRadius: 4,
498
+ }]
499
+ },
500
+ options: {
501
+ indexAxis:'y',
502
+ responsive:true, maintainAspectRatio:false,
503
+ plugins: { legend:{display:false} },
504
+ scales: {
505
+ x: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true },
506
+ y: { grid:{display:false}, ticks:{font:{size:10}} }
507
+ }
508
+ }
509
+ });
510
+
511
+ // 5. TOP USERS
512
+ destroyChart('user');
513
+ const topUsers = Object.entries(globalUserMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
514
+ charts.user = new Chart(document.getElementById('chartUser'), {
515
+ type: 'bar',
516
+ data: {
517
+ labels: topUsers.map(([k])=>'@'+k),
518
+ datasets: [{
519
+ label:'Tweet',
520
+ data: topUsers.map(([,v])=>v),
521
+ backgroundColor: CHART_COLORS.accentDim,
522
+ borderColor: CHART_COLORS.accent,
523
+ borderWidth: 1,
524
+ borderRadius: 4,
525
+ }]
526
+ },
527
+ options: {
528
+ indexAxis:'y',
529
+ responsive:true, maintainAspectRatio:false,
530
+ plugins: { legend:{display:false} },
531
+ scales: {
532
+ x: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true },
533
+ y: { grid:{display:false} }
534
+ }
535
+ }
536
+ });
537
+
538
+ // 6. CONFIDENCE HISTOGRAM
539
+ destroyChart('confidence');
540
+ const binLabels = globalConfidenceBins.map((_,i)=>`${(i*10)}–${(i+1)*10}%`);
541
+ charts.confidence = new Chart(document.getElementById('chartConfidence'), {
542
+ type: 'bar',
543
+ data: {
544
+ labels: binLabels,
545
+ datasets: [{
546
+ label:'Jumlah',
547
+ data: globalConfidenceBins,
548
+ backgroundColor: globalConfidenceBins.map((_,i) => {
549
+ if (i < 4) return CHART_COLORS.neg;
550
+ if (i < 7) return CHART_COLORS.neu;
551
+ return CHART_COLORS.pos;
552
+ }),
553
+ borderWidth: 0,
554
+ borderRadius: 3,
555
+ }]
556
+ },
557
+ options: {
558
+ responsive:true, maintainAspectRatio:false,
559
+ plugins: { legend:{display:false} },
560
+ scales: {
561
+ x: { grid:{color:'rgba(255,255,255,0.04)'}, ticks:{font:{size:9}} },
562
+ y: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true }
563
+ }
564
+ }
565
+ });
566
+
567
+ // 7. RADAR — avg engagement per sentiment
568
+ destroyChart('radar');
569
+ const sentKeys = ['Positif','Negatif','Netral'];
570
+ charts.radar = new Chart(document.getElementById('chartRadar'), {
571
+ type: 'radar',
572
+ data: {
573
+ labels: ['Like','Retweet','Reply'],
574
+ datasets: sentKeys.map((s,i) => {
575
+ const e = globalRadarStats[s];
576
+ const ct = e.ct || 1;
577
+ const col = [CHART_COLORS.pos, CHART_COLORS.neg, CHART_COLORS.neu][i];
578
+ const colDim = [CHART_COLORS.posDim, CHART_COLORS.negDim, CHART_COLORS.neuDim][i];
579
+ return {
580
+ label: s,
581
+ data: [+(e.fav/ct).toFixed(1), +(e.rt/ct).toFixed(1), +(e.rep/ct).toFixed(1)],
582
+ borderColor: col,
583
+ backgroundColor: colDim,
584
+ borderWidth: 2,
585
+ pointRadius: 3,
586
+ };
587
+ })
588
+ },
589
+ options: {
590
+ responsive:true, maintainAspectRatio:false,
591
+ plugins: { legend:{ labels:{boxWidth:10,padding:12} } },
592
+ scales: {
593
+ r: {
594
+ grid:{ color:'rgba(255,255,255,0.06)' },
595
+ angleLines:{ color:'rgba(255,255,255,0.06)' },
596
+ pointLabels:{ font:{size:11}, color:'#8890a4' },
597
+ ticks:{ backdropColor:'transparent', font:{size:9} },
598
+ }
599
+ }
600
+ }
601
+ });
602
+
603
+ // 8. TOPIC SCORE — avg confidence per topic grouped
604
+ destroyChart('topicScore');
605
+ const top6Conf = Object.entries(globalTopicMap).sort((a,b)=>b[1].total-a[1].total).slice(0,6);
606
+ charts.topicScore = new Chart(document.getElementById('chartTopicScore'), {
607
+ type: 'bar',
608
+ data: {
609
+ labels: top6Conf.map(([k]) => k.length>30 ? k.slice(0,30)+'…' : k),
610
+ datasets: [
611
+ { label:'Avg Score Positif', data: top6Conf.map(([,v])=> v.total?+(v.posConf/v.total).toFixed(3):0), backgroundColor:CHART_COLORS.pos, borderRadius:3, borderWidth:0 },
612
+ { label:'Avg Score Negatif', data: top6Conf.map(([,v])=> v.total?+(v.negConf/v.total).toFixed(3):0), backgroundColor:CHART_COLORS.neg, borderRadius:3, borderWidth:0 },
613
+ { label:'Avg Score Netral', data: top6Conf.map(([,v])=> v.total?+(v.neuConf/v.total).toFixed(3):0), backgroundColor:CHART_COLORS.neu, borderRadius:3, borderWidth:0 },
614
+ ]
615
+ },
616
+ options: {
617
+ responsive:true, maintainAspectRatio:false,
618
+ plugins: { legend:{labels:{boxWidth:10,padding:14}} },
619
+ scales: {
620
+ x: { grid:{color:'rgba(255,255,255,0.04)'}, ticks:{maxRotation:30,font:{size:9}} },
621
+ y: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true, max:1,
622
+ ticks:{ callback: v => (v*100).toFixed(0)+'%' } }
623
+ }
624
+ }
625
+ });
626
+ }
627
+
628
+ // ──────────────────────────────────────────
629
+ // TABLE
630
+ // ──────────────────────────────────────────
631
+ function renderTable() {
632
+ const body = document.getElementById('tableBody');
633
+ const start = (currentPage - 1) * PAGE_SIZE;
634
+ const end = start + PAGE_SIZE;
635
+ const page = filteredRows.slice(start, end);
636
+
637
+ body.innerHTML = page.map((r, idx) => `
638
+ <tr>
639
+ <td class="td-no">${r.no}</td>
640
+ <td class="td-text">${esc(r.raw.slice(0,100))}<span style="color:var(--text-muted)">${r.raw.length>100?'…':''}</span></td>
641
+ <td class="td-clean">${esc(r.cleaned.slice(0,80))}<span style="color:var(--text-muted)">${r.cleaned.length>80?'…':''}</span></td>
642
+ <td class="td-user">${esc(r.username)}</td>
643
+ <td class="td-loc">${esc(r.location)}</td>
644
+ <td>
645
+ <span class="badge-sent ${r.sentiment==='Positif'?'badge-pos':r.sentiment==='Negatif'?'badge-neg':'badge-neu'}">
646
+ ${r.sentiment}
647
+ </span>
648
+ </td>
649
+ <td>
650
+ <div class="conf-wrap">
651
+ <span class="conf-val">${(r.confidence*100).toFixed(1)}%</span>
652
+ <div class="conf-bar-track">
653
+ <div class="conf-bar-fill ${r.sentiment==='Positif'?'conf-fill-pos':r.sentiment==='Negatif'?'conf-fill-neg':'conf-fill-neu'}" style="width:${(r.confidence*100).toFixed(0)}%"></div>
654
+ </div>
655
+ </div>
656
+ </td>
657
+ <td class="engage-val">${r.fav}L / ${r.rt}RT / ${r.rep}RP</td>
658
+ </tr>
659
+ `).join('');
660
+
661
+ renderPagination();
662
+ document.getElementById('tableInfo').textContent =
663
+ `Menampilkan ${Math.min(start+1,filteredRows.length)}–${Math.min(end,filteredRows.length)} dari ${filteredRows.length} data`;
664
+ }
665
+
666
+ function esc(str) {
667
+ return String(str)
668
+ .replace(/&/g,'&amp;')
669
+ .replace(/</g,'&lt;')
670
+ .replace(/>/g,'&gt;')
671
+ .replace(/"/g,'&quot;');
672
+ }
673
+
674
+ function renderPagination() {
675
+ const totalPages = Math.ceil(filteredRows.length / PAGE_SIZE);
676
+ const pg = document.getElementById('pagination');
677
+ if (totalPages <= 1) { pg.innerHTML = ''; return; }
678
+
679
+ const range = [];
680
+ range.push(1);
681
+ if (currentPage > 3) range.push('…');
682
+ for (let i = Math.max(2, currentPage-1); i <= Math.min(totalPages-1, currentPage+1); i++) range.push(i);
683
+ if (currentPage < totalPages - 2) range.push('…');
684
+ if (totalPages > 1) range.push(totalPages);
685
+
686
+ pg.innerHTML = `
687
+ <button class="page-btn" ${currentPage===1?'disabled':''} onclick="gotoPage(${currentPage-1})">Prev</button>
688
+ ${range.map(p => p==='…'
689
+ ? `<span class="page-btn" style="cursor:default;opacity:0.4">…</span>`
690
+ : `<button class="page-btn ${p===currentPage?'active':''}" onclick="gotoPage(${p})">${p}</button>`
691
+ ).join('')}
692
+ <button class="page-btn" ${currentPage===totalPages?'disabled':''} onclick="gotoPage(${currentPage+1})">Next</button>
693
+ `;
694
+ }
695
+
696
+ window.gotoPage = function(p) {
697
+ const totalPages = Math.ceil(filteredRows.length / PAGE_SIZE);
698
+ currentPage = Math.max(1, Math.min(p, totalPages));
699
+ renderTable();
700
+ document.getElementById('data').scrollIntoView({behavior:'smooth'});
701
+ };
702
+
703
+ // ──────────────────────────────────────────
704
+ // FILTER & SEARCH
705
+ // ──────────────────────────────────────────
706
+ function applyFilter() {
707
+ const sent = document.getElementById('filterSentiment').value;
708
+ const q = document.getElementById('searchInput').value.toLowerCase().trim();
709
+
710
+ filteredRows = allRows.filter(r => {
711
+ const sentOk = sent === 'all' || r.sentiment === sent;
712
+ const searchOk = !q || r.raw.toLowerCase().includes(q) || r.username.toLowerCase().includes(q);
713
+ return sentOk && searchOk;
714
+ });
715
+ currentPage = 1;
716
+ renderTable();
717
+ }
718
+
719
+ // ──────────────────────────────────────────
720
+ // EXPORT
721
+ // ──────────────────────────────────────────
722
+
723
+
724
+ function download(filename, content, type) {
725
+ const blob = new Blob([content], {type});
726
+ const url = URL.createObjectURL(blob);
727
+ const a = document.createElement('a');
728
+ a.href = url; a.download = filename; a.click();
729
+ setTimeout(()=>URL.revokeObjectURL(url), 1000);
730
+ }
731
+
732
+ // ──────────────────────────────────────────
733
+ // MAIN FLOW
734
+ // ──────────────────────────────────────────
735
+ async function handleFiles(filesArray) {
736
+ const files = Array.from(filesArray).filter(f => f.name.endsWith('.csv'));
737
+ if (files.length === 0) {
738
+ alert('Silakan pilih setidaknya satu file dengan format .csv');
739
+ return;
740
+ }
741
+
742
+ // Calculate total payload size for progress tracking
743
+ const totalBytesAll = files.reduce((sum, f) => sum + f.size, 0);
744
+
745
+ // Show progress UI
746
+ document.getElementById('progressWrap').style.display = 'block';
747
+ document.getElementById('uploadInner').style.opacity = '0.4';
748
+ document.getElementById('uploadInner').style.pointerEvents = 'none';
749
+
750
+ // --- Reset Global State Before New Parsing ---
751
+ allRows = [];
752
+ filteredRows = [];
753
+ globalStats = { total: 0, pos: 0, neg: 0, neu: 0, engage: 0, dates: { min: null, max: null } };
754
+ globalTimeMap = {};
755
+ globalTopicMap = {};
756
+ globalLocMap = {};
757
+ globalUserMap = {};
758
+ globalConfidenceBins = Array(10).fill(0);
759
+ globalRadarStats = {
760
+ Positif: { fav:0, rt:0, rep:0, ct:0 },
761
+ Negatif: { fav:0, rt:0, rep:0, ct:0 },
762
+ Netral: { fav:0, rt:0, rep:0, ct:0 }
763
+ };
764
+ sampleCleaningExample = null;
765
+
766
+ try {
767
+ let startingBytesCompleted = 0;
768
+
769
+ for (let i = 0; i < files.length; i++) {
770
+ const file = files[i];
771
+ // Await parsing stream
772
+ const bytesProcessed = await parseAndAnalyzeStreaming(file, totalBytesAll, startingBytesCompleted);
773
+ startingBytesCompleted += bytesProcessed;
774
+ }
775
+
776
+ if (globalStats.total === 0) {
777
+ alert('Tidak ada data teks yang valid ditemukan dalam file CSV yang dipilih.');
778
+ resetUpload();
779
+ return;
780
+ }
781
+
782
+ filteredRows = [...allRows]; // For data table max 50k
783
+
784
+ // Show results
785
+ setTimeout(() => {
786
+ document.getElementById('resultsSection').style.display = 'block';
787
+ renderStats();
788
+ renderCharts();
789
+ renderTable();
790
+ document.getElementById('ringkasan').scrollIntoView({behavior:'smooth'});
791
+ }, 300);
792
+
793
+ } catch(err) {
794
+ console.error(err);
795
+ alert('Terjadi kesalahan saat memproses file: ' + err.message);
796
+ resetUpload();
797
+ }
798
+ }
799
+
800
+ function resetUpload() {
801
+ document.getElementById('uploadInner').style.opacity = '1';
802
+ document.getElementById('uploadInner').style.pointerEvents = '';
803
+ document.getElementById('progressWrap').style.display = 'none';
804
+ setProgress(0, '');
805
+ }
806
+
807
+ // ──────────────────────────────────────────
808
+ // EVENT LISTENERS
809
+ // ──────────────────────────────────────────
810
+ document.addEventListener('DOMContentLoaded', () => {
811
+ const zone = document.getElementById('uploadZone');
812
+ const input = document.getElementById('csvInput');
813
+
814
+ // File input (multiple)
815
+ input.addEventListener('change', e => {
816
+ if (e.target.files.length > 0) handleFiles(e.target.files);
817
+ });
818
+
819
+ // Drag & Drop (multiple)
820
+ zone.addEventListener('dragover', e => {
821
+ e.preventDefault();
822
+ zone.classList.add('drag-over');
823
+ });
824
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
825
+ zone.addEventListener('drop', e => {
826
+ e.preventDefault();
827
+ zone.classList.remove('drag-over');
828
+ if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
829
+ });
830
+
831
+ // Filter & search
832
+ document.getElementById('filterSentiment').addEventListener('change', applyFilter);
833
+ document.getElementById('searchInput').addEventListener('input', applyFilter);
834
+
835
+ // Reset
836
+ document.getElementById('btnReset').addEventListener('click', () => {
837
+ document.getElementById('resultsSection').style.display = 'none';
838
+ resetUpload();
839
+ allRows = []; filteredRows = [];
840
+ document.getElementById('csvInput').value = '';
841
+ document.getElementById('upload').scrollIntoView({behavior:'smooth'});
842
+ });
843
+
844
+ // Cleaning panel toggle
845
+ document.getElementById('cleaningToggle').addEventListener('click', () => {
846
+ const body = document.getElementById('cleaningBody');
847
+ const chev = document.querySelector('.chevron');
848
+ const open = body.style.display === 'none';
849
+ body.style.display = open ? 'block' : 'none';
850
+ chev.classList.toggle('open', open);
851
+ });
852
+
853
+ // Sortable headers
854
+ document.querySelectorAll('th.sortable').forEach(th => {
855
+ th.addEventListener('click', () => {
856
+ const col = th.dataset.col;
857
+ if (sortCol === col) { sortDir *= -1; }
858
+ else { sortCol = col; sortDir = -1; }
859
+ filteredRows.sort((a,b) => {
860
+ const va = a[col], vb = b[col];
861
+ if (typeof va === 'number') return (va - vb) * sortDir;
862
+ return String(va).localeCompare(String(vb)) * sortDir;
863
+ });
864
+ currentPage = 1;
865
+ renderTable();
866
+ });
867
+ });
868
+ });
js/chart.js ADDED
The diff for this file is too large to render. See raw diff
 
js/cleaning.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+ SM.injectLayout('nav-cleaning');
3
+ SM.setChartDefaults();
4
+
5
+ const store = SM.loadData();
6
+ if (!store) {
7
+ window.location.replace('upload.html');
8
+ throw new Error('No data — redirecting to upload');
9
+ }
10
+ const { rows, meta } = store;
11
+ document.getElementById('topbarMeta').textContent = `${meta.filename} — ${rows.length} tweets`;
12
+
13
+ // ── Pipeline Steps Controls ──
14
+ const stepEnabled = Object.fromEntries(SM.PIPELINE_STEPS.map(s => [s.id, true]));
15
+
16
+ document.getElementById('pipelineSteps').innerHTML = SM.PIPELINE_STEPS.map((s,i) => `
17
+ <div class="pipeline-step">
18
+ <div class="step-num">${i+1}</div>
19
+ <div class="step-info">
20
+ <div class="step-label">${s.label}</div>
21
+ <div class="step-desc">${s.desc}</div>
22
+ </div>
23
+ <div class="step-toggle on" id="toggle_${s.id}" data-step="${s.id}"></div>
24
+ </div>
25
+ `).join('');
26
+
27
+ document.querySelectorAll('.step-toggle').forEach(tog => {
28
+ tog.addEventListener('click', () => {
29
+ const id = tog.dataset.step;
30
+ stepEnabled[id] = !stepEnabled[id];
31
+ tog.classList.toggle('on', stepEnabled[id]);
32
+ updateDemo();
33
+ });
34
+ });
35
+
36
+ // ── Custom clean using enabled steps only ──
37
+ function cleanCustom(raw) {
38
+ let t = raw || '';
39
+ for (const s of SM.PIPELINE_STEPS) {
40
+ if (stepEnabled[s.id]) t = SM.cleanStep(t, s.id);
41
+ }
42
+ return t;
43
+ }
44
+
45
+ // ── Demo Textarea ──
46
+ function updateDemo() {
47
+ const raw = document.getElementById('demoInput').value;
48
+ if (!raw.trim()) { document.getElementById('stepPipeline').innerHTML = ''; return; }
49
+
50
+ let t = raw;
51
+ const lines = SM.PIPELINE_STEPS.map((s,i) => {
52
+ const before = t;
53
+ if (stepEnabled[s.id]) t = SM.cleanStep(t, s.id);
54
+ const changed = before !== t;
55
+ const removed = before.length - t.length;
56
+ return `<div class="step-line ${changed?'changed':''}">
57
+ <div class="step-line-num">${i+1}</div>
58
+ <div class="step-line-name">${s.label.replace(/\d+\.\s/,'')}</div>
59
+ <div class="step-line-text">${SM.esc(t)||'<em style="color:var(--tx3)">(kosong)</em>'}</div>
60
+ <div class="step-diff">${changed?`−${removed} char`:'sama'}</div>
61
+ </div>`;
62
+ });
63
+
64
+ document.getElementById('stepPipeline').innerHTML = lines.join('');
65
+ }
66
+
67
+ const demoInput = document.getElementById('demoInput');
68
+ demoInput.addEventListener('input', updateDemo);
69
+ // Default example
70
+ demoInput.value = rows.length ? rows[0].raw : '@liputan6dotcom Gak sia-sia mendukung #Prabowo-Gibran! https://t.co/abc123 😍 Data ekonomi tumbuh 5.17%';
71
+ updateDemo();
72
+
73
+ // ── Dataset Stats ──
74
+ const allBefore = rows.map(r => r.wordsBefore);
75
+ const allAfter = rows.map(r => r.wordsAfter);
76
+ const avgBefore = SM.avg(allBefore).toFixed(1);
77
+ const avgAfter = SM.avg(allAfter).toFixed(1);
78
+ const avgReduction = rows.map(r => r.wordsBefore > 0 ? Math.round((1-r.wordsAfter/r.wordsBefore)*100) : 0);
79
+ const avgRed = SM.avg(avgReduction).toFixed(1);
80
+ const emptyAfter = rows.filter(r => r.wordsAfter === 0).length;
81
+
82
+ document.getElementById('cleaningStats').innerHTML = [
83
+ { label:'Rata-rata Kata Sebelum', value: avgBefore },
84
+ { label:'Rata-rata Kata Sesudah', value: avgAfter },
85
+ { label:'Rata-rata Reduksi', value: avgRed + '%' },
86
+ { label:'Teks Kosong Setelah', value: emptyAfter },
87
+ { label:'Stopwords Digunakan', value: SM.STOPWORDS.size },
88
+ { label:'Total Token Unik', value: new Set(rows.flatMap(r=>r.cleaned.split(' ').filter(Boolean))).size },
89
+ ].map(s => `<span class="stat-pill"><strong>${s.value}</strong> ${s.label}</span>`).join('');
90
+
91
+ // ── Reduction Distribution Chart ──
92
+ const redBins = Array(10).fill(0);
93
+ avgReduction.forEach(v => { const i=Math.min(Math.floor(v/10),9); redBins[i]++; });
94
+ SM.mkChart('chartReduction', {
95
+ type:'bar',
96
+ data:{ labels:redBins.map((_,i)=>`${i*10}–${(i+1)*10}%`),
97
+ datasets:[{ label:'Tweet',data:redBins,
98
+ backgroundColor:redBins.map((_,i)=>i<3?SM.C.neg:i<7?SM.C.neu:SM.C.pos),
99
+ borderWidth:0, borderRadius:3 }]},
100
+ options:{ responsive:true, maintainAspectRatio:false,
101
+ plugins:{legend:{display:false}},
102
+ scales:{ x:{grid:{color:SM.gridColor},ticks:{font:{size:9}}}, y:{grid:{color:SM.gridColor},beginAtZero:true} }
103
+ }
104
+ });
105
+
106
+ // ── Top Words Chart ──
107
+ const wordFreq = {};
108
+ rows.forEach(r => {
109
+ r.cleaned.split(/\s+/).filter(Boolean).forEach(w => { wordFreq[w]=(wordFreq[w]||0)+1; });
110
+ });
111
+ const topWords = Object.entries(wordFreq).sort((a,b)=>b[1]-a[1]).slice(0,15);
112
+ SM.mkChart('chartWords', {
113
+ type:'bar',
114
+ data:{ labels:topWords.map(([k])=>k),
115
+ datasets:[{ label:'Frekuensi',data:topWords.map(([,v])=>v),
116
+ backgroundColor:SM.C.a1d, borderColor:SM.C.a1, borderWidth:1, borderRadius:3 }]},
117
+ options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
118
+ plugins:{legend:{display:false}},
119
+ scales:{ x:{grid:{color:SM.gridColor},beginAtZero:true}, y:{grid:{display:false},ticks:{font:{size:10}}} }
120
+ }
121
+ });
122
+
123
+ // ── Before/After Table ──
124
+ document.getElementById('cleanTableBody').innerHTML = rows.slice(0,20).map(r => {
125
+ const pct = r.wordsBefore ? Math.round((1-r.wordsAfter/r.wordsBefore)*100) : 0;
126
+ const pctCol = pct>70?'var(--pos)':pct>40?'var(--neu)':'var(--neg)';
127
+ return `<tr>
128
+ <td class="td-no">${r.id}</td>
129
+ <td class="td-trunc" title="${SM.esc(r.raw)}">${SM.esc(r.raw.slice(0,90))}${r.raw.length>90?'…':''}</td>
130
+ <td class="td-trunc" title="${SM.esc(r.cleaned)}">${r.cleaned?SM.esc(r.cleaned.slice(0,70))+'…':'<em style="color:var(--tx3)">(kosong)</em>'}</td>
131
+ <td style="text-align:center">${r.wordsBefore}</td>
132
+ <td style="text-align:center">${r.wordsAfter}</td>
133
+ <td style="text-align:center;font-weight:600;color:${pctCol}">${pct}%</td>
134
+ </tr>`;
135
+ }).join('');
js/dashboard.js ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ // ── Guard BEFORE DOM ──
4
+ const _store = SM.loadData();
5
+ if (!_store) {
6
+ window.location.replace('upload.html');
7
+ throw new Error('No data — redirecting');
8
+ }
9
+
10
+ SM.injectLayout('nav-dashboard');
11
+
12
+ document.addEventListener('DOMContentLoaded', function () {
13
+ SM.setChartDefaults();
14
+
15
+ const { rows, meta } = _store;
16
+ const pos = rows.filter(r => r.sentiment === 'Positif');
17
+ const neg = rows.filter(r => r.sentiment === 'Negatif');
18
+ const neu = rows.filter(r => r.sentiment === 'Netral');
19
+ const n = rows.length;
20
+ const totalEngage = rows.reduce((s,r) => s + r.engagement, 0);
21
+ const avgConf = SM.avg(rows.map(r => r.confidence));
22
+ const sentScore = +((pos.length - neg.length) / n).toFixed(3);
23
+
24
+ // Topbar
25
+ document.getElementById('topbarMeta').textContent = `${meta.filename} — ${n} tweets`;
26
+ if (meta.dateMin) {
27
+ const d1 = new Date(meta.dateMin), d2 = new Date(meta.dateMax);
28
+ document.getElementById('datePeriod').textContent =
29
+ `${isNaN(d1.getTime())?meta.dateMin:d1.toLocaleDateString('id-ID')} – ${isNaN(d2.getTime())?meta.dateMax:d2.toLocaleDateString('id-ID')}`;
30
+ }
31
+
32
+ // ── KPI Cards ──
33
+ document.getElementById('kpiGrid').innerHTML = [
34
+ { color:'blue', label:'Total Tweet', value: SM.fmt(n), sub: meta.filename },
35
+ { color:'green', label:'Positif', value: SM.fmt(pos.length), sub: `${((pos.length/n)*100).toFixed(1)}% dari total` },
36
+ { color:'red', label:'Negatif', value: SM.fmt(neg.length), sub: `${((neg.length/n)*100).toFixed(1)}% dari total` },
37
+ { color:'yellow', label:'Netral', value: SM.fmt(neu.length), sub: `${((neu.length/n)*100).toFixed(1)}% dari total` },
38
+ { color:'cyan', label:'Total Engagement', value: SM.fmt(totalEngage), sub: 'Like + RT + Reply + Quote' },
39
+ { color:'purple', label:'Avg Engagement', value: SM.fmt(Math.round(totalEngage/n)), sub: 'Per tweet' },
40
+ { color:'pink', label:'Avg Kepercayaan', value: (avgConf*100).toFixed(1)+'%', sub: 'Skor confidence model' },
41
+ { color:'orange', label:'Skor Sentimen',
42
+ value: sentScore >= 0 ? '+'+sentScore : String(sentScore),
43
+ sub: 'Skala −1.0 hingga +1.0',
44
+ delta: sentScore > 0.2 ? 'up' : sentScore < -0.2 ? 'down' : 'mid',
45
+ deltaText: sentScore > 0.2 ? 'Cenderung Positif' : sentScore < -0.2 ? 'Cenderung Negatif' : 'Cenderung Netral' },
46
+ ].map(k => `
47
+ <div class="kpi kpi-${k.color}">
48
+ <div class="kpi-label">${k.label}</div>
49
+ <div class="kpi-value">${k.value}</div>
50
+ <div class="kpi-sub">${k.sub}</div>
51
+ ${k.delta ? `<div class="kpi-delta ${k.delta}">${k.deltaText}</div>` : ''}
52
+ </div>`).join('');
53
+
54
+ // ── Gauge ──
55
+ try {
56
+ const canvas = document.getElementById('chartGauge');
57
+ if (canvas) {
58
+ const ctx = canvas.getContext('2d');
59
+ const W = canvas.width, H = canvas.height;
60
+ const pct = (sentScore + 1) / 2;
61
+ const startA = Math.PI, angle = startA + pct * Math.PI;
62
+ const cx = W/2, cy = H - 10, r = 100;
63
+ ctx.clearRect(0,0,W,H);
64
+ ctx.beginPath(); ctx.arc(cx,cy,r,startA,2*Math.PI);
65
+ ctx.lineWidth=18; ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineCap='round'; ctx.stroke();
66
+ const g = ctx.createLinearGradient(cx-r,cy,cx+r,cy);
67
+ g.addColorStop(0,'#f87171'); g.addColorStop(0.5,'#fbbf24'); g.addColorStop(1,'#34d399');
68
+ ctx.beginPath(); ctx.arc(cx,cy,r,startA,angle);
69
+ ctx.lineWidth=18; ctx.strokeStyle=g; ctx.lineCap='round'; ctx.stroke();
70
+ const nx=cx+(r-9)*Math.cos(angle), ny=cy+(r-9)*Math.sin(angle);
71
+ ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(nx,ny);
72
+ ctx.lineWidth=2; ctx.strokeStyle='#e8eaf0'; ctx.lineCap='round'; ctx.stroke();
73
+ ctx.beginPath(); ctx.arc(cx,cy,5,0,2*Math.PI); ctx.fillStyle='#e8eaf0'; ctx.fill();
74
+ ctx.font='10px Inter'; ctx.fillStyle='#525b72'; ctx.textAlign='center';
75
+ ctx.fillText('−1.0',cx-r+6,cy+16); ctx.fillText('0',cx,cy-r-6); ctx.fillText('+1.0',cx+r-6,cy+16);
76
+ }
77
+ document.getElementById('gaugeLabel').textContent =
78
+ `Skor: ${sentScore>=0?'+':''}${sentScore} — ${
79
+ sentScore>0.3?'Sangat Positif':sentScore>0.05?'Positif':sentScore>-0.05?'Netral':sentScore>-0.3?'Negatif':'Sangat Negatif'}`;
80
+ } catch(e) { console.error('Gauge error:', e); }
81
+
82
+ // ── Donut ──
83
+ try {
84
+ SM.mkChart('chartDonut', {
85
+ type: 'doughnut',
86
+ data: { labels:['Positif','Negatif','Netral'],
87
+ datasets:[{ data:[pos.length,neg.length,neu.length],
88
+ backgroundColor:[SM.C.pos,SM.C.neg,SM.C.neu], borderWidth:0, hoverOffset:8 }] },
89
+ options: { responsive:true, maintainAspectRatio:false, cutout:'68%',
90
+ plugins:{ legend:{display:false},
91
+ tooltip:{callbacks:{label:c=>` ${c.label}: ${c.parsed} (${((c.parsed/n)*100).toFixed(1)}%)`}}}}
92
+ });
93
+ document.getElementById('legendDonut').innerHTML =
94
+ [['Positif',SM.C.pos,pos.length],['Negatif',SM.C.neg,neg.length],['Netral',SM.C.neu,neu.length]]
95
+ .map(([l,c,v])=>`<div class="legend-item"><div class="legend-dot" style="background:${c}"></div>${l} (${v})</div>`).join('');
96
+ } catch(e) { console.error('Donut error:', e); }
97
+
98
+ // ── Time trend ──
99
+ try {
100
+ const tMap = {};
101
+ rows.forEach(r => {
102
+ if (!r.date) return;
103
+ const d = new Date(r.date);
104
+ const k = isNaN(d.getTime()) ? r.date.slice(0,10) : `${String(d.getHours()).padStart(2,'0')}:00`;
105
+ if (!tMap[k]) tMap[k] = {Positif:0,Negatif:0,Netral:0};
106
+ tMap[k][r.sentiment]++;
107
+ });
108
+ const tL = Object.keys(tMap).sort();
109
+ SM.mkChart('chartTrend', {
110
+ type:'line',
111
+ data:{ labels:tL, datasets:[
112
+ {label:'Positif',data:tL.map(k=>tMap[k].Positif),borderColor:SM.C.pos,backgroundColor:SM.C.posDim,tension:.4,fill:true,pointRadius:3},
113
+ {label:'Negatif',data:tL.map(k=>tMap[k].Negatif),borderColor:SM.C.neg,backgroundColor:SM.C.negDim,tension:.4,fill:true,pointRadius:3},
114
+ {label:'Netral', data:tL.map(k=>tMap[k].Netral), borderColor:SM.C.neu,backgroundColor:SM.C.neuDim,tension:.4,fill:true,pointRadius:3},
115
+ ]},
116
+ options:{responsive:true,maintainAspectRatio:false,
117
+ interaction:{mode:'index',intersect:false},
118
+ plugins:{legend:{labels:{boxWidth:10,padding:14}}},
119
+ scales:{ x:{grid:{color:SM.gridColor}}, y:{grid:{color:SM.gridColor},beginAtZero:true} }
120
+ }
121
+ });
122
+ } catch(e) { console.error('Trend error:', e); }
123
+
124
+ // ── Top tweets ──
125
+ function tweetCard(r, cls) {
126
+ return `<div class="tweet-card ${cls}">
127
+ <div class="tweet-text">${SM.esc(r.raw.slice(0,120))}${r.raw.length>120?'…':''}</div>
128
+ <div class="tweet-meta">
129
+ <span>@${SM.esc(r.username)}</span><span>${r.fav} like</span>
130
+ <span>${r.rt} RT</span><span>${(r.confidence*100).toFixed(0)}% conf.</span>
131
+ </div></div>`;
132
+ }
133
+ const topPos = [...pos].sort((a,b)=>b.confidence-a.confidence).slice(0,5);
134
+ const topNeg = [...neg].sort((a,b)=>b.confidence-a.confidence).slice(0,5);
135
+ const emptyPos = `
136
+ <div class="empty-state">
137
+ <div class="empty-state-title">Tidak Ada Data</div>
138
+ <div class="empty-state-desc">Belum ada tweet dengan sentimen positif.</div>
139
+ </div>`;
140
+ const emptyNeg = `
141
+ <div class="empty-state">
142
+ <div class="empty-state-title">Tidak Ada Data</div>
143
+ <div class="empty-state-desc">Belum ada tweet dengan sentimen negatif.</div>
144
+ </div>`;
145
+
146
+ document.getElementById('positiveTweets').innerHTML = topPos.map(r=>tweetCard(r,'pos')).join('') || emptyPos;
147
+ document.getElementById('negativeTweets').innerHTML = topNeg.map(r=>tweetCard(r,'neg')).join('') || emptyNeg;
148
+
149
+ // ── Leaderboard ──
150
+ const userEng = {};
151
+ rows.forEach(r => { userEng[r.username] = (userEng[r.username]||0) + r.engagement; });
152
+ document.getElementById('leaderboard').innerHTML =
153
+ Object.entries(userEng).sort((a,b)=>b[1]-a[1]).slice(0,10)
154
+ .map(([u,e],i) => `<div class="leaderboard-row">
155
+ <div class="lb-rank">${i+1}.</div>
156
+ <div class="lb-name">@${SM.esc(u)}</div>
157
+ <div class="lb-val">${SM.fmt(e)} eng.</div>
158
+ </div>`).join('');
159
+
160
+ // ── Insights ──
161
+ const posRatio = (pos.length/n*100).toFixed(1), negRatio = (neg.length/n*100).toFixed(1);
162
+ const highConf = rows.filter(r=>r.confidence>0.85).length;
163
+ const locCounts = {}; rows.forEach(r => { locCounts[r.location]=(locCounts[r.location]||0)+1; });
164
+ const topLoc = Object.entries(locCounts).sort((a,b)=>b[1]-a[1])[0];
165
+ const posConf = pos.length ? SM.avg(pos.map(r=>r.confidence)) : 0;
166
+ const negConf = neg.length ? SM.avg(neg.map(r=>r.confidence)) : 0;
167
+
168
+ document.getElementById('insights').innerHTML = [
169
+ { color: pos.length>neg.length ? SM.C.pos : SM.C.neg,
170
+ text: `${pos.length>neg.length?'Mayoritas sentimen Positif':'Sentimen Negatif dominan'} (${posRatio}% vs ${negRatio}%).` },
171
+ { color: SM.C.a1,
172
+ text: `${highConf} tweet (${((highConf/n)*100).toFixed(1)}%) kepercayaan model di atas 85%.` },
173
+ ...(topLoc ? [{ color:SM.C.a2, text:`Lokasi paling aktif: ${topLoc[0]} dengan ${topLoc[1]} tweet.` }]:[]),
174
+ { color: SM.C.a3, text:`Rata-rata engagement per tweet: ${SM.fmt(Math.round(totalEngage/n))}.` },
175
+ { color: SM.C.a4, text:`Avg kepercayaan — Positif: ${(posConf*100).toFixed(1)}%, Negatif: ${(negConf*100).toFixed(1)}%.` },
176
+ ].map(ins => `<div class="insight-item">
177
+ <div class="insight-dot" style="background:${ins.color}"></div>
178
+ <div class="insight-text">${ins.text}</div>
179
+ </div>`).join('');
180
+ });
js/data.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+ SM.injectLayout('nav-data');
3
+
4
+ const store = SM.loadData();
5
+ if (!store) {
6
+ window.location.replace('upload.html');
7
+ throw new Error('No data — redirecting to upload');
8
+ }
9
+ const { rows, meta } = store;
10
+ document.getElementById('topbarMeta').textContent = `${meta.filename} — ${rows.length} tweets`;
11
+
12
+ // ── Column definitions ──
13
+ const COLS = [
14
+ { key:'id', label:'No', visible:true, sortable:true },
15
+ { key:'raw', label:'Teks Asli', visible:true, sortable:false },
16
+ { key:'cleaned', label:'Teks Bersih', visible:false, sortable:false },
17
+ { key:'username', label:'Username', visible:true, sortable:true },
18
+ { key:'location', label:'Lokasi', visible:true, sortable:true },
19
+ { key:'date', label:'Waktu', visible:true, sortable:true },
20
+ { key:'sentiment', label:'Sentimen', visible:true, sortable:true },
21
+ { key:'confidence', label:'Kepercayaan', visible:true, sortable:true },
22
+ { key:'fav', label:'Like', visible:true, sortable:true },
23
+ { key:'rt', label:'Retweet', visible:true, sortable:true },
24
+ { key:'rep', label:'Reply', visible:false, sortable:true },
25
+ { key:'qot', label:'Quote', visible:false, sortable:true },
26
+ { key:'engagement', label:'Engagement', visible:true, sortable:true },
27
+ ];
28
+
29
+ // Populate location & user dropdowns
30
+ const locs = [...new Set(rows.map(r=>(r.location||'').trim()||'—'))].sort();
31
+ const users = [...new Set(rows.map(r=>r.username))].sort();
32
+ const fLoc = document.getElementById('fLocation');
33
+ const fUser = document.getElementById('fUser');
34
+ locs.forEach(l => { const o=document.createElement('option'); o.value=l; o.textContent=l; fLoc.appendChild(o); });
35
+ users.forEach(u => { const o=document.createElement('option'); o.value=u; o.textContent='@'+u; fUser.appendChild(o); });
36
+
37
+ // Column toggle buttons
38
+ document.getElementById('colToggle').innerHTML = COLS.map((c,i) =>
39
+ `<div class="col-pill ${c.visible?'on':''}" data-colidx="${i}">${c.label}</div>`
40
+ ).join('');
41
+ document.querySelectorAll('.col-pill').forEach(pill => {
42
+ pill.addEventListener('click', () => {
43
+ const i = +pill.dataset.colidx;
44
+ COLS[i].visible = !COLS[i].visible;
45
+ pill.classList.toggle('on', COLS[i].visible);
46
+ renderTable();
47
+ });
48
+ });
49
+
50
+ // ── State ──
51
+ let filtered = [...rows];
52
+ let currentPage = 1;
53
+ let pageSize = 20;
54
+ let sortKey = 'id', sortDir = 1;
55
+ let expandedRows = new Set();
56
+
57
+ // ── Filters ──
58
+ function applyFilters() {
59
+ const s = document.getElementById('fSentiment').value;
60
+ const loc = document.getElementById('fLocation').value;
61
+ const usr = document.getElementById('fUser').value;
62
+ const q = document.getElementById('fSearch').value.toLowerCase().trim();
63
+ const minE= parseInt(document.getElementById('fMinEngage').value)||0;
64
+ const minC= (parseFloat(document.getElementById('fMinConf').value)||0)/100;
65
+
66
+ filtered = rows.filter(r =>
67
+ (s==='all' || r.sentiment===s) &&
68
+ (loc==='all' || (r.location||'—')===loc) &&
69
+ (usr==='all' || r.username===usr) &&
70
+ (r.engagement >= minE) &&
71
+ (r.confidence >= minC) &&
72
+ (!q || r.raw.toLowerCase().includes(q) || r.username.toLowerCase().includes(q) || r.cleaned.toLowerCase().includes(q))
73
+ );
74
+
75
+ // Sort
76
+ filtered.sort((a,b)=>{
77
+ const va=a[sortKey], vb=b[sortKey];
78
+ if(typeof va==='number') return (va-vb)*sortDir;
79
+ return String(va).localeCompare(String(vb))*sortDir;
80
+ });
81
+
82
+ currentPage = 1;
83
+ renderTable();
84
+ }
85
+
86
+ ['fSentiment','fLocation','fUser'].forEach(id => document.getElementById(id).addEventListener('change', applyFilters));
87
+ document.getElementById('fSearch').addEventListener('input', applyFilters);
88
+ document.getElementById('fMinEngage').addEventListener('input', applyFilters);
89
+ document.getElementById('fMinConf').addEventListener('input', applyFilters);
90
+ document.getElementById('pageSize').addEventListener('change', e => { pageSize=+e.target.value; currentPage=1; renderTable(); });
91
+ document.getElementById('btnReset').addEventListener('click', () => {
92
+ document.getElementById('fSentiment').value='all';
93
+ document.getElementById('fLocation').value='all';
94
+ document.getElementById('fUser').value='all';
95
+ document.getElementById('fSearch').value='';
96
+ document.getElementById('fMinEngage').value='';
97
+ document.getElementById('fMinConf').value='';
98
+ // Refresh custom select labels after reset
99
+ ['fSentiment','fLocation','fUser','pageSize'].forEach(id => {
100
+ const el = document.getElementById(id);
101
+ if (el) el.dispatchEvent(new Event('_csdRefresh'));
102
+ });
103
+ applyFilters();
104
+ });
105
+
106
+ // ── Table HEAD ──
107
+ function renderHead() {
108
+ const visCols = COLS.filter(c=>c.visible);
109
+ document.getElementById('tableHead').innerHTML = `<tr>
110
+ ${visCols.map(c => {
111
+ const isSorted = sortKey === c.key;
112
+ const sortIcon = isSorted
113
+ ? (sortDir === 1
114
+ ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="width:12px;height:12px;margin-left:4px"><polyline points="18 15 12 9 6 15"/></svg>'
115
+ : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="width:12px;height:12px;margin-left:4px"><polyline points="6 9 12 15 18 9"/></svg>')
116
+ : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;margin-left:4px;opacity:0.2"><polyline points="6 9 12 15 18 9"/></svg>';
117
+
118
+ return `<th ${c.sortable ? `data-sort="${c.key}" class="${isSorted ? 'active-sort' : ''}"` : ''}>
119
+ <div style="display:flex; align-items:center; justify-content:space-between">
120
+ ${c.label}
121
+ ${c.sortable ? sortIcon : ''}
122
+ </div>
123
+ </th>`;
124
+ }).join('')}
125
+ </tr>`;
126
+ document.querySelectorAll('th[data-sort]').forEach(th => {
127
+ th.addEventListener('click', () => {
128
+ if(sortKey===th.dataset.sort) sortDir*=-1;
129
+ else { sortKey=th.dataset.sort; sortDir=-1; }
130
+ applyFilters(); // Use applyFilters to trigger sorting before re-render
131
+ });
132
+ });
133
+ }
134
+
135
+ // ── Table BODY ──
136
+ function renderTable() {
137
+ renderHead();
138
+ const visCols = COLS.filter(c=>c.visible);
139
+ const start = (currentPage-1)*pageSize;
140
+ const page = filtered.slice(start, start+pageSize);
141
+
142
+ document.getElementById('tableBody').innerHTML = page.map(r => {
143
+ const cells = visCols.map(c => {
144
+ const v = r[c.key];
145
+ if(c.key==='sentiment') return `<td><span class="badge badge-${r.sentiment==='Positif'?'pos':r.sentiment==='Negatif'?'neg':'neu'}">${r.sentiment}</span></td>`;
146
+ if(c.key==='confidence') return `<td>
147
+ <div class="conf-row">
148
+ <span class="conf-num">${(v*100).toFixed(1)}%</span>
149
+ <div class="conf-track"><div class="conf-fill conf-${r.sentiment==='Positif'?'pos':r.sentiment==='Negatif'?'neg':'neu'}" style="width:${(v*100).toFixed(0)}%"></div></div>
150
+ </div></td>`;
151
+ if(c.key==='raw') return `<td class="td-trunc" title="${SM.esc(v)}">${SM.esc(v.slice(0,80))}${v.length>80?'…':''}</td>`;
152
+ if(c.key==='cleaned') return `<td class="td-trunc" title="${SM.esc(v)}">${SM.esc(v.slice(0,60))}${v.length>60?'…':''}</td>`;
153
+ if(c.key==='date') {
154
+ const d=new Date(v); return `<td class="td-trunc-sm">${isNaN(d)?v:d.toLocaleTimeString('id-ID',{hour:'2-digit',minute:'2-digit'})}</td>`;
155
+ }
156
+ if(c.key==='username') return `<td style="font-weight:500;color:var(--tx1);white-space:nowrap">@${SM.esc(v)}</td>`;
157
+ if(c.key==='id') return `<td class="td-no">${v}</td>`;
158
+ return `<td>${SM.esc(String(v))}</td>`;
159
+ }).join('');
160
+
161
+ return `<tr data-rowid="${r.id}">
162
+ ${cells}
163
+ </tr>`;
164
+ }).join('');
165
+
166
+ renderPagination();
167
+ document.getElementById('tableInfo').textContent =
168
+ `Menampilkan ${Math.min(start+1,filtered.length)}–${Math.min(start+pageSize,filtered.length)} dari ${filtered.length} data (total ${rows.length})`;
169
+ }
170
+
171
+ function renderPagination() {
172
+ const total = Math.ceil(filtered.length/pageSize);
173
+ const pg = document.getElementById('pagination');
174
+ if(total<=1) { pg.innerHTML=''; return; }
175
+ const range=[1];
176
+ if(currentPage>3) range.push('…');
177
+ for(let i=Math.max(2,currentPage-1);i<=Math.min(total-1,currentPage+1);i++) range.push(i);
178
+ if(currentPage<total-2) range.push('…');
179
+ if(total>1) range.push(total);
180
+
181
+ pg.innerHTML = `
182
+ <button class="pg-btn" ${currentPage===1?'disabled':''} onclick="goPage(${currentPage-1})" title="Previous">
183
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><polyline points="15 18 9 12 15 6"/></svg>
184
+ </button>
185
+ ${range.map(p=>p==='…'?`<span class="pg-btn dots">…</span>`
186
+ :`<button class="pg-btn ${p===currentPage?'active':''}" onclick="goPage(${p})">${p}</button>`).join('')}
187
+ <button class="pg-btn" ${currentPage===total?'disabled':''} onclick="goPage(${currentPage+1})" title="Next">
188
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><polyline points="9 18 15 12 9 6"/></svg>
189
+ </button>`;
190
+ }
191
+ window.goPage = function(p) {
192
+ currentPage=Math.max(1,Math.min(p,Math.ceil(filtered.length/pageSize)));
193
+ expandedRows.clear(); renderTable();
194
+ document.getElementById('dataTable').scrollIntoView({behavior:'smooth'});
195
+ };
196
+
197
+ // Initial render
198
+ applyFilters();
199
+
200
+ // ── Custom Dropdowns ──
201
+ SM.initCustomSelect(document.getElementById('fSentiment'), {
202
+ showDots: {
203
+ 'all': null,
204
+ 'Positif': '#34d399',
205
+ 'Negatif': '#f87171',
206
+ 'Netral': '#fbbf24',
207
+ }
208
+ });
209
+ SM.initCustomSelect(document.getElementById('fLocation'));
210
+ SM.initCustomSelect(document.getElementById('fUser'));
211
+ SM.initCustomSelect(document.getElementById('pageSize'), { compact: true });
212
+ SM.initCustomNumber(document.getElementById('fMinEngage'));
213
+ SM.initCustomNumber(document.getElementById('fMinConf'));
js/demo.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DEMO_CSV_P1 = `full_text,username,location,created_at,favorite_count,retweet_count,reply_count,quote_count
2
+ "SentiMeter sangat membantu saya menganalisis sentimen dengan cepat! #SentiMeter #AnalisisSentimen",budi_santoso,Jakarta,"2024-03-15T10:00:00Z",12,4,2,1
3
+ "Kenapa aplikasinya lemot banget ya pas upload file gede? #TechIssue #SentiMeter",siti_maemunah,Bandung,"2024-03-15T10:05:00Z",2,5,10,0
4
+ "Biasa aja sih, fitur pembersih teksnya standar. #AI #NLP",andi_wijaya,Surabaya,"2024-03-15T10:10:00Z",5,1,1,0
5
+ "Wah keren nih IndoBERT-nya akurat bgt klasifikasinya! #IndoBERT #MachineLearning",joko_wow,Medan,"2024-03-15T10:15:00Z",45,20,5,3
6
+ "Sedang mencoba demo data 300 tweet, penasaran hasilnya. #DemoData #SentiMeter",ani_linda,Yogyakarta,"2024-03-15T10:20:00Z",8,2,0,0
7
+ "Aplikasi ini sangat inovatif dan berguna untuk riset pasar. #Innovation #MarketResearch",marketing_pro,Jakarta,"2024-03-15T10:25:00Z",30,12,4,2
8
+ "Error terus pas mau buka riwayat, ada solusi? #HelpDesk #SentiMeter",user_bingung,Semarang,"2024-03-15T10:30:00Z",1,0,3,0
9
+ "Interface-nya cakep, dark mode-nya premium! #UIDesign #Premium",design_lover,Bali,"2024-03-15T10:35:00Z",100,50,12,5
10
+ "Membosankan, tidak banyak perubahan dari versi sebelumnya. #Review #Apps",kritikus_pedas,Malang,"2024-03-15T10:40:00Z",15,3,20,2
11
+ "Sangat terbantu untuk tugas akhir saya, terima kasih! #Skripsi #Mahasiswa",mahasiswa_akhir,Palembang,"2024-03-15T10:45:00Z",55,15,8,4
12
+ "Gak paham cara pakainya, ribet! #UserExperience #SentiMeter",gaptek_id,Makassar,"2024-03-15T10:50:00Z",0,0,5,0
13
+ "Kecepatan klasifikasinya luar biasa, salut. #TechReview #IndoBERT",tech_enthusiast,Jakarta,"2024-03-15T10:55:00Z",88,34,10,6
14
+ "Harga langganannya kalau ada nanti bakal mahal gak ya? #Subscription #Fintech",economist_view,Tangerang,"2024-03-15T11:00:00Z",12,2,4,1
15
+ "Mantap jiwa, lanjut terus pengembangannya! #OpenSource #Dev",semangat_45,Bekasi,"2024-03-15T11:05:00Z",22,8,2,0
16
+ "Sangat buruk, sering force close di browser saya. #BugReport #Crash",bad_luck_user,Depok,"2024-03-15T11:10:00Z",3,1,12,0
17
+ "Tim pengembangnya responsif sekali, bug cepat difix. #CustomerSuccess #DevLife",satisfied_customer,Jakarta,"2024-03-15T11:15:00Z",140,60,25,10
18
+ "Bagus tapi butuh fitur lebih banyak lagi. #FutureUpdate #Request",feature_hunter,Sidoarjo,"2024-03-15T11:20:00Z",9,2,3,1
19
+ "Indobert emang joss buat bahasa Indonesia. #NLP #IndoBERT",ai_researcher,Bandung,"2024-03-15T11:25:00Z",210,120,40,15
20
+ "Gak sabar nunggu update fitur barunya! #ComingSoon #Tech",hype_beast,Surabaya,"2024-03-15T11:30:00Z",44,12,5,2
21
+ "Kurang suka sama font-nya, kegedean. #UIDesign #Feedback",font_critic,Jakarta,"2024-03-15T11:35:00Z",2,0,15,0
22
+ "Terima kasih SentiMeter, analisanya tajam! #DataScience #Insight",data_analyst,Yogyakarta,"2024-03-15T11:40:00Z",77,22,6,3
23
+ "Kapan ada fitur buat sentiment bahasa daerah? #RequestFeature #LocalLanguage",lokal_pride,Solo,"2024-03-15T11:45:00Z",18,4,12,1
24
+ "Luar biasa, data 100k diproses lancar jaya. #Optimization #BigData",big_data_king,Jakarta,"2024-03-15T11:50:00Z",320,150,60,25
25
+ "Hmm, kok hasil sentimennya agak aneh ya? #SentimentAnalysis #IndoBERT",curious_mind,Bandung,"2024-03-15T11:55:00Z",6,2,8,0
26
+ "Puas banget pakai SentiMeter untuk monitor brand. #Branding #SocialMedia",social_media_mgr,Surabaya,"2024-03-15T12:00:00Z",95,30,10,5
27
+ "Aplikasi sampah, gak guna! #Fail #Haters",haters_gonna_hate,Batam,"2024-03-15T12:05:00Z",0,0,50,5
28
+ "Fitur cleaning-nya oke banget, teks jadi rapi. #DataCleaning #Efficiency",clean_text_fan,Manado,"2024-03-15T12:10:00Z",24,6,2,1
29
+ "Semoga kedepannya makin jago NLP-nya. #ArtificialIntelligence #NLP",future_tech,Jakarta,"2024-03-15T12:15:00Z",15,5,1,0
30
+ "Navigasinya intuitif, gampang dimengerti. #UX #UserExperience",ux_designer,Medan,"2024-03-15T12:20:00Z",66,18,4,2
31
+ "Kenapa gak gratis aja selamanya? #FreeApps #StudentLife",freebie_hunter,Balikpapan,"2024-03-15T12:25:00Z",4,1,30,0
32
+ "Support platform-nya juara, fast response. #GreatService #Apps",happy_dev,Padang,"2024-03-15T12:30:00Z",82,25,7,4
33
+ "Warna UI-nya bikin mata sakit kalau malem. #UIFeedback #DarkMode",night_owl,Jakarta,"2024-03-15T12:35:00Z",5,1,12,1
34
+ "Sangat merekomendasikan SentiMeter buat analisis! #Recommended #Tool",expert_recommends,Malang,"2024-03-15T12:40:00Z",110,40,15,8
35
+ "Gak bisa upload file XLSX ya? #XLSX #FileFormat",excel_fanatic,Pontianak,"2024-03-15T12:45:00Z",2,0,4,0
36
+ "Bintang 5 buat kemampuannya klasifikasi kalimat sarkas. #Sarcasm #IndoBERT",sarcasm_detect,Denpasar,"2024-03-15T12:50:00Z",150,70,22,12
37
+ "Masih banyak typo di teks dokumentasinya. #Documentation #Typo",typo_hunter,Cirebon,"2024-03-15T12:55:00Z",1,0,5,0
38
+ "Inovatif, hemat waktu banget buat report mingguan. #Efficiency #Report",fast_reporter,Jakarta,"2024-03-15T13:00:00Z",42,12,3,1
39
+ "Update terus ya biar gak ketinggalan zaman. #Trends #SentiMeter",tech_follower,Bandung,"2024-03-15T13:05:00Z",18,5,2,0
40
+ "Suka banget sama visualisasi chart-nya. #Analytics #VisualData",visual_art_fan,Surabaya,"2024-03-15T13:10:00Z",63,15,5,2
41
+ "Biasa aja, kayak tools lain pada umumnya. #Neutral #Apps Review",skeptic_guy,Semarang,"2024-03-15T13:15:00Z",7,1,4,0
42
+ "Keren parah! Gak nyangka buatan lokal. #BanggaBuatanIndonesia #LocalApps",proud_indo,Jakarta,"2024-03-15T13:20:00Z",245,110,35,18
43
+ "Kenapa datanya gak bisa diexport ke PDF? #PDF #Export",pdf_lover,Lampung,"2024-03-15T13:25:00Z",4,0,8,0
44
+ "Stabil dan ringan digunakan. #Performance #Tech",stable_performance,Banda_Aceh,"2024-03-15T13:30:00Z",28,10,2,1
45
+ "User manualnya kurang lengkap nih. #Documentation #UserHelp",manual_reader,Tasikmalaya,"2024-03-15T13:35:00Z",3,0,6,0
46
+ "Seneng banget nemu tools kayak gini, memudahkan hidup. #Useful #Productivity",convenience_seeker,Jakarta,"2024-03-15T13:40:00Z",89,28,9,5
47
+ "Dashboard-nya informatif sekali. #UserInterface #Data",info_collector,Samarinda,"2024-03-15T13:45:00Z",12,4,1,0
48
+ "Gimana ya cara hapus data lama? #Help #DataManagement",old_user_care,Banjarmasin,"2024-03-15T13:50:00Z",1,0,2,0
49
+ "Mantul! Akurasinya tinggi dibanding sebelah. #Accuracy #IndoBERT",val_expert,Jakarta,"2024-03-15T13:55:00Z",165,55,18,9
50
+ "Fitur search-nya cepet meskipun data ribuan. #Optimization #Search",search_pro,Denpasar,"2024-03-15T14:00:00Z",52,15,4,2
51
+ "Desainnya clean, profesional banget. #Professional #UIDesign",clean_design_fan,Ternate,"2024-03-15T14:05:00Z",74,20,6,3`;
52
+
53
+ const DEMO_CSV_P2 = Array.from({length: 250}, (_, i) => {
54
+ const sentiments = ["Sangat suka dengan aplikasi ini!", "Biasa saja, butuh perbaikan.", "Buruk sekali, tidak merekomendasikan.", "Luar biasa keren!", "Membosankan.", "Inovasi bagus.", "Cukup membantu.", "Kurang fitur.", "Stabil dan cepat.", "Error lagi."];
55
+ const hashtags = ["#SentiMeter", "#IndoBERT", "#Analytics", "#DataScience", "#Indonesia", "#Tech", "#SocialMedia", "#MachineLearning", "#AI", "#NLP"];
56
+ const users = ["user_gen_", "tweet_fan_", "indo_bert_", "data_guy_", "tech_wiz_"];
57
+ const locs = ["Jakarta", "Bandung", "Surabaya", "Medan", "Bali", "Yogyakarta", "Semarang", "Makassar"];
58
+
59
+ const s = sentiments[i % sentiments.length];
60
+ const h1 = hashtags[i % hashtags.length];
61
+ const h2 = hashtags[(i + 3) % hashtags.length];
62
+ const u = users[i % users.length] + i;
63
+ const l = locs[i % locs.length];
64
+ const f = Math.floor(Math.random() * 50);
65
+ const r = Math.floor(Math.random() * 20);
66
+ const rep = Math.floor(Math.random() * 10);
67
+ const q = Math.floor(Math.random() * 5);
68
+
69
+ return `"${s} Tweet demo nomor ${i+51} ${h1} ${h2}",${u},${l},2024-03-15T15:00:00Z,${f},${r},${rep},${q}`;
70
+ }).join("\n");
71
+
72
+ window.DEMO_CSV = DEMO_CSV_P1 + "\n" + DEMO_CSV_P2;
js/history.js ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════
2
+ SentiMeter — history.js
3
+ Riwayat Analisis Core Logic
4
+ ═══════════════════════════════════════════════════ */
5
+ 'use strict';
6
+
7
+ SM.injectLayout('nav-history');
8
+
9
+ document.addEventListener('DOMContentLoaded', () => {
10
+ renderHistory();
11
+ });
12
+
13
+ function renderHistory() {
14
+ const container = document.getElementById('historyContainer');
15
+ const history = SM.getHistory();
16
+ const badge = document.getElementById('histCount');
17
+ if (badge) badge.textContent = history.length || 0;
18
+
19
+ if (!history || history.length === 0) {
20
+ container.innerHTML = `
21
+ <div class="history-empty reveal-up" style="--d:100ms">
22
+ <h3>Belum Ada Riwayat Analisis</h3>
23
+ <p>Log aktivitas analisis Anda akan muncul di sini. Silakan mulai dengan mengunggah dan memproses file CSV pertama Anda.</p>
24
+ <a href="upload.html" class="btn btn-primary btn-lg empty-cta">Mulai Analisis Sekarang</a>
25
+ </div>
26
+ `;
27
+ return;
28
+ }
29
+
30
+ let html = '';
31
+ history.forEach((h, index) => {
32
+ const d = h.data;
33
+ const meta = d.meta;
34
+ const dateStr = new Date(h.savedAt).toLocaleDateString('id-ID', {
35
+ day: 'numeric', month: 'long', year: 'numeric',
36
+ hour: '2-digit', minute: '2-digit'
37
+ });
38
+
39
+ const totalPos = d.rows.filter(r => r.sentiment === 'Positif').length;
40
+ const totalNeg = d.rows.filter(r => r.sentiment === 'Negatif').length;
41
+ const totalNeu = d.rows.filter(r => r.sentiment === 'Netral').length;
42
+
43
+ const total = meta.totalRows || 1;
44
+ const pPos = ((totalPos / total) * 100).toFixed(1);
45
+ const pNeg = ((totalNeg / total) * 100).toFixed(1);
46
+ const pNeu = ((totalNeu / total) * 100).toFixed(1);
47
+
48
+ html += `
49
+ <div class="hist-card" style="--d:${index * 80}ms">
50
+ <div class="hist-card-top">
51
+ <div class="hist-info">
52
+ <div class="hist-filename">${meta.filename || 'data.csv'}</div>
53
+ <div class="hist-meta">
54
+ <span class="hist-date">Dianalisis pada ${dateStr}</span>
55
+ </div>
56
+ </div>
57
+ <div class="hist-actions">
58
+ <button class="btn btn-primary btn-sm" onclick="viewHistory('${h.id}')">Lihat Dashboard</button>
59
+ <button class="btn btn-outline btn-sm" style="color:var(--neg);border-color:var(--neg-d)" onclick="removeHistory('${h.id}')">Hapus</button>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="hist-visual">
64
+ <div class="hist-bar-outer">
65
+ <div class="hist-bar-segment pos" style="width: ${pPos}%"></div>
66
+ <div class="hist-bar-segment neu" style="width: ${pNeu}%"></div>
67
+ <div class="hist-bar-segment neg" style="width: ${pNeg}%"></div>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="hist-stats">
72
+ <div class="hist-stat-box">
73
+ <span class="hist-stat-label">Total Data</span>
74
+ <span class="hist-stat-val">${SM.fmt(meta.totalRows)} <span class="hist-stat-unit">Baris</span></span>
75
+ </div>
76
+ <div class="hist-stat-box">
77
+ <span class="hist-stat-label">Positif</span>
78
+ <span class="hist-stat-val pos">${pPos}%</span>
79
+ </div>
80
+ <div class="hist-stat-box">
81
+ <span class="hist-stat-label">Netral</span>
82
+ <span class="hist-stat-val neu">${pNeu}%</span>
83
+ </div>
84
+ <div class="hist-stat-box">
85
+ <span class="hist-stat-label">Negatif</span>
86
+ <span class="hist-stat-val neg">${pNeg}%</span>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ `;
91
+ });
92
+
93
+ container.innerHTML = html;
94
+ }
95
+
96
+ window.viewHistory = function(id) {
97
+ if (SM.loadHistoryItem(id)) {
98
+ SM.showToast('Data riwayat berhasil dimuat.');
99
+ setTimeout(() => {
100
+ window.location.href = 'dashboard.html';
101
+ }, 500);
102
+ } else {
103
+ SM.showToast('Gagal memuat riwayat data.', 'error');
104
+ }
105
+ };
106
+
107
+ window.removeHistory = function(id) {
108
+ const history = SM.getHistory();
109
+ const item = history.find(h => h.id === id);
110
+ const filename = item ? item.data.meta.filename : 'data';
111
+
112
+ SM.showModal({
113
+ title: 'Hapus Riwayat?',
114
+ message: `Apakah Anda yakin ingin menghapus riwayat analisis untuk file <b>${SM.esc(filename)}</b>? Tindakan ini tidak dapat dibatalkan.`,
115
+ type: 'error',
116
+ confirmText: 'Hapus Riwayat',
117
+ cancelText: 'Batal',
118
+ showCancel: true,
119
+ isDanger: true,
120
+ onConfirm: () => {
121
+ SM.deleteHistoryItem(id);
122
+ renderHistory();
123
+ SM.injectLayout('nav-history'); // Refresh sidebar state
124
+ SM.showToast('Riwayat berhasil dihapus', 'success');
125
+ },
126
+ onCancel: () => {
127
+ // Do nothing, just close
128
+ }
129
+ });
130
+ };
js/index.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════
2
+ intro.js — SentiMeter Intro Page Logic
3
+ ═══════════════════════════════════════════════ */
4
+
5
+ 'use strict';
6
+
7
+ // ── INJECT SIDEBAR ───────────────────────────
8
+ injectLayout('nav-intro');
9
+
10
+ // ── SCROLL REVEAL ───────────────────────────
11
+ function initScrollReveal() {
12
+ // Immediately trigger hero reveals
13
+ document.querySelectorAll('#introHero .reveal-up').forEach(el => {
14
+ const delay = parseInt(getComputedStyle(el).getPropertyValue('--d')) || 0;
15
+ setTimeout(() => el.classList.add('visible'), delay);
16
+ });
17
+
18
+ // Observe all other reveal-up elements
19
+ const io = new IntersectionObserver((entries) => {
20
+ entries.forEach(e => {
21
+ if (e.isIntersecting) {
22
+ e.target.classList.add('visible');
23
+ io.unobserve(e.target);
24
+ }
25
+ });
26
+ }, { rootMargin: '0px 0px -50px 0px', threshold: 0.05 });
27
+
28
+ document.querySelectorAll('.reveal-up:not(#introHero .reveal-up)').forEach(el => io.observe(el));
29
+ }
30
+
31
+ // ── COUNTER ANIMATION ───────────────────────
32
+ function animateCounters() {
33
+ document.querySelectorAll('.ih-stat-n').forEach(el => {
34
+ const raw = el.dataset.to;
35
+ if (raw === '∞') {
36
+ setTimeout(() => {
37
+ el.textContent = '∞';
38
+ el.style.transition = 'transform 0.25s';
39
+ el.style.transform = 'scale(1.15)';
40
+ setTimeout(() => el.style.transform = '', 250);
41
+ }, 700);
42
+ return;
43
+ }
44
+ const target = parseInt(raw);
45
+ let v = 0;
46
+ const step = Math.ceil(target / 25);
47
+ const iv = setInterval(() => {
48
+ v = Math.min(v + step, target);
49
+ el.textContent = v;
50
+ if (v >= target) {
51
+ clearInterval(iv);
52
+ el.style.transition = 'transform 0.2s';
53
+ el.style.transform = 'scale(1.12)';
54
+ setTimeout(() => el.style.transform = '', 200);
55
+ }
56
+ }, 45);
57
+ });
58
+ }
59
+
60
+ // ── CSV DEMO ─────────────────────────────────
61
+ const CSV_ROWS = [
62
+ { n: '—', h: true, text: 'full_text', user: 'username', date: 'created_at', num: 'retweet' },
63
+ { n: '1', h: false, text: 'pemilu harus diulang...', user: '@politikus_id', date: '2024-02-14', num: '1234' },
64
+ { n: '2', h: false, text: 'kpk tetap tegak berdiri...', user: '@warga_aktif', date: '2024-02-15', num: '234' },
65
+ { n: '3', h: false, text: 'bangga kemajuan ibu kota...', user: '@jakarta_cinta',date: '2024-02-15', num: '44' },
66
+ { n: '4', h: false, text: 'reformasi birokrasi berjalan...',user: '@pegawai_asn', date: '2024-02-16', num: '89' },
67
+ ];
68
+
69
+ function buildCodeDemo() {
70
+ const target = document.getElementById('codeDemo');
71
+ if (!target) return;
72
+ target.innerHTML = '';
73
+
74
+ CSV_ROWS.forEach((row, i) => {
75
+ const div = document.createElement('div');
76
+ div.className = 'csv-row';
77
+ div.style.opacity = '0';
78
+ div.style.transition = `opacity 0.3s ease ${i * 130}ms`;
79
+
80
+ const cls = row.h ? 'csv-h' : '';
81
+ const textCls = row.h ? 'csv-h' : 'csv-text';
82
+ const userCls = row.h ? 'csv-h' : 'csv-user';
83
+ const dateCls = row.h ? 'csv-h' : 'csv-date';
84
+ const numCls = row.h ? 'csv-h' : 'csv-num';
85
+
86
+ div.innerHTML = `
87
+ <span class="csv-lnum">${row.n}</span>
88
+ <span class="${textCls}" title="${row.text}">${row.text.length > 18 ? row.text.slice(0,18)+'…' : row.text}</span>
89
+ <span class="${userCls}">${row.user}</span>
90
+ <span class="${dateCls}">${row.date}</span>
91
+ <span class="${numCls}">${row.num}</span>
92
+ `;
93
+ target.appendChild(div);
94
+
95
+ setTimeout(() => div.style.opacity = '1', 350 + i * 130);
96
+ });
97
+
98
+ // Cursor row
99
+ const cur = document.createElement('div');
100
+ cur.className = 'csv-row';
101
+ cur.innerHTML = '<span class="csv-lnum">5</span><span class="csv-cursor"></span>';
102
+ cur.style.opacity = '0';
103
+ cur.style.transition = `opacity 0.3s ease ${CSV_ROWS.length * 130 + 350}ms`;
104
+ target.appendChild(cur);
105
+ setTimeout(() => cur.style.opacity = '1', CSV_ROWS.length * 130 + 650);
106
+ }
107
+
108
+ // ── SMOOTH "LEARN MORE" ─────────────────────
109
+ function initSmoothScroll() {
110
+ document.getElementById('learnMoreBtn')?.addEventListener('click', e => {
111
+ e.preventDefault();
112
+ document.getElementById('cara-pakai')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
113
+ });
114
+ }
115
+
116
+ // ── INIT ─────────────────────────────────────
117
+ document.addEventListener('DOMContentLoaded', () => {
118
+ initScrollReveal();
119
+ setTimeout(animateCounters, 650);
120
+ buildCodeDemo();
121
+ initSmoothScroll();
122
+ });
js/shared.js ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════
2
+ SentiMeter — shared.js
3
+ Shared Engine: IndoBERT API · Cleaning · Store · Nav
4
+ ═══════════════════════════════════════════════════ */
5
+ 'use strict';
6
+
7
+ // ─── INDOBERT API CONFIG ────────────────────────────
8
+ const INDOBERT_API_URL = '/api/sentiment';
9
+ const INDOBERT_HEALTH_URL = '/api/health';
10
+ const INDOBERT_BATCH_SIZE = 32;
11
+
12
+ // ─── STOPWORDS INDONESIA ────────────────────────────
13
+ const STOPWORDS = new Set([
14
+ 'yang','dan','di','ke','dari','dengan','ini','itu','adalah','ada','akan',
15
+ 'untuk','telah','sudah','dalam','pada','atau','juga','tidak','bisa',
16
+ 'oleh','sebagai','dapat','lebih','saat','secara','kami','kita','mereka',
17
+ 'dia','ia','saya','kamu','anda','selama','setelah','sebelum','atas','bawah',
18
+ 'hal','jika','namun','tapi','tetapi','bahwa','karena','ketika','sehingga',
19
+ 'namanya','pun','lagi','masih','sama','seperti','bagi','semua','selain',
20
+ 'maupun','antara','agar','supaya','tanpa','melalui','hingga','sampai',
21
+ 'bahkan','begitu','meski','meskipun','walaupun','tersebut','nya','lah',
22
+ 'kah','pernah','selalu','serta','beberapa','suatu','hanya','para',
23
+ 'tentang','siapa','apa','bagaimana','kapan','dimana','mengapa','kenapa',
24
+ 'sebuah','seorang','berbagai','banyak','sedikit','lebih','kurang','sangat',
25
+ 'amat','sekali','cukup','hampir','sudah','belum','sedang','pasti','mungkin',
26
+ 'tentu','ya','tidak','bukan','jangan','bila','andai','seandainya','adapun',
27
+ 'adapun','demikian','sehingga','akibat','maka','oleh karena','oleh sebab',
28
+ ]);
29
+
30
+ // ─── PIPELINE STEP LABELS ───────────────────────────
31
+ const PIPELINE_STEPS = [
32
+ { id:'lowercase', label:'1. Lowercase', desc:'Mengubah semua huruf menjadi huruf kecil.' },
33
+ { id:'url', label:'2. Hapus URL', desc:'Menghapus semua tautan http/https.' },
34
+ { id:'mention', label:'3. Hapus Mention', desc:'Menghapus @username.' },
35
+ { id:'hashtag', label:'4. Hapus Hashtag', desc:'Mengonversi #tag menjadi kata biasa.' },
36
+ { id:'emoji', label:'5. Hapus Emoji', desc:'Menghapus karakter emoji unicode.' },
37
+ { id:'special', label:'6. Hapus Karakter Khusus', desc:'Hanya huruf, angka, spasi dipertahankan.' },
38
+ { id:'number', label:'7. Hapus Angka', desc:'Menghapus angka yang berdiri sendiri.' },
39
+ { id:'whitespace', label:'8. Normalisasi Spasi', desc:'Menghilangkan spasi ganda/leading/trailing.' },
40
+ { id:'stopword', label:'9. Hapus Stopwords', desc:'Menghapus kata-kata umum tidak bermakna.' },
41
+ ];
42
+
43
+ // ─── TEXT CLEANER ───────────────────────────────────
44
+ function cleanStep(text, stepId) {
45
+ switch (stepId) {
46
+ case 'lowercase': return text.toLowerCase();
47
+ case 'url': return text.replace(/https?:\/\/[^\s]+/g,'').replace(/www\.[^\s]+/g,'');
48
+ case 'mention': return text.replace(/@\w+/g,'');
49
+ case 'hashtag': return text.replace(/#(\w+)/g,'$1');
50
+ case 'emoji': return text.replace(/[\u{1F000}-\u{1FFFF}]/gu,'').replace(/[\u{2600}-\u{27BF}]/gu,'');
51
+ case 'special': return text.replace(/[^a-z0-9 .,]/g,' ');
52
+ case 'number': return text.replace(/\b\d[\d.,]*\b/g,'');
53
+ case 'whitespace': return text.replace(/\s+/g,' ').trim();
54
+ case 'stopword': return text.split(' ').filter(w=>w.length>1&&!STOPWORDS.has(w)).join(' ');
55
+ default: return text;
56
+ }
57
+ }
58
+
59
+ function cleanText(raw) {
60
+ let t = raw || '';
61
+ for (const s of PIPELINE_STEPS) t = cleanStep(t, s.id);
62
+ return t;
63
+ }
64
+
65
+ function cleanSteps(raw) {
66
+ const steps = [];
67
+ let t = raw || '';
68
+ for (const s of PIPELINE_STEPS) {
69
+ const before = t;
70
+ t = cleanStep(t, s.id);
71
+ steps.push({ ...s, before, after: t });
72
+ }
73
+ return steps;
74
+ }
75
+
76
+ // ─── CLASSIFY (IndoBERT API) ────────────────────────
77
+
78
+ /**
79
+ * Check if the IndoBERT model backend is available.
80
+ * Returns { ok: true/false, error: string|null }
81
+ */
82
+ async function checkModelHealth() {
83
+ try {
84
+ const res = await fetch(INDOBERT_HEALTH_URL, { method: 'GET' });
85
+ const data = await res.json();
86
+ if (data.status === 'ok') return { ok: true, error: null };
87
+ return { ok: false, error: data.error || 'Model tidak tersedia' };
88
+ } catch (e) {
89
+ return { ok: false, error: 'Server IndoBERT tidak dapat dijangkau. Pastikan server Python sedang berjalan.' };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Send a batch of texts to the IndoBERT API for sentiment classification.
95
+ * Returns array of { label: 'Positif'|'Netral'|'Negatif', score: number }
96
+ * Throws an error if the API call fails.
97
+ */
98
+ async function classifyBatch(texts) {
99
+ const res = await fetch(INDOBERT_API_URL, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ texts }),
103
+ });
104
+
105
+ if (!res.ok) {
106
+ const errData = await res.json().catch(() => ({}));
107
+ throw new Error(errData.message || `Server error: ${res.status}`);
108
+ }
109
+
110
+ return await res.json();
111
+ }
112
+
113
+ // ─── HELPERS ────────────────────────────────────────
114
+ function fmt(n) {
115
+ if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
116
+ if (n >= 1e3) return (n/1e3).toFixed(1)+'K';
117
+ return String(n);
118
+ }
119
+ function esc(s) {
120
+ return String(s||'')
121
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
122
+ .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
123
+ }
124
+ function avg(arr) { return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : 0; }
125
+
126
+ // ─── DATA STORE (localStorage) ───────────────────
127
+ const STORE_KEY = 'sentimeter_data';
128
+
129
+ function saveData(rows, meta) {
130
+ try {
131
+ const dataObj = { rows, meta };
132
+ localStorage.setItem(STORE_KEY, JSON.stringify(dataObj));
133
+ // Also save to history
134
+ saveToHistory(dataObj);
135
+ } catch(e) {
136
+ // quota exceeded – silently fail
137
+ console.warn('localStorage full, using memory fallback', e);
138
+ window._sentimeterData = { rows, meta };
139
+ }
140
+ }
141
+
142
+ function loadData() {
143
+ try {
144
+ const raw = localStorage.getItem(STORE_KEY);
145
+ if (raw) return JSON.parse(raw);
146
+ } catch(e) {}
147
+ return window._sentimeterData || null;
148
+ }
149
+
150
+ function hasData() { return !!loadData(); }
151
+
152
+ // ─── HISTORY STORE (localStorage) ─────────────────
153
+ const HISTORY_KEY = 'sentimeter_history';
154
+ const MAX_HISTORY = 10; // Keep last 10 analyses
155
+
156
+ function getHistory() {
157
+ try {
158
+ const raw = localStorage.getItem(HISTORY_KEY);
159
+ if (raw) return JSON.parse(raw);
160
+ } catch(e) {}
161
+ return [];
162
+ }
163
+
164
+ function saveToHistory(dataObj) {
165
+ try {
166
+ let history = getHistory();
167
+ // Create a history item (we store the full dataObj to allow reloading)
168
+ // To save space, we might compress or limit, but for now we store as is
169
+ // since localStorage typically allows 5MB. We'll add a timestamp ID.
170
+ const historyItem = {
171
+ id: 'hist_' + Date.now(),
172
+ savedAt: new Date().toISOString(),
173
+ data: dataObj
174
+ };
175
+
176
+ // Check if identical filename exists, remove old one
177
+ history = history.filter(h => h.data.meta.filename !== dataObj.meta.filename);
178
+
179
+ history.unshift(historyItem);
180
+
181
+ // Enforce limit and memory size by checking quota
182
+ while (history.length > MAX_HISTORY) {
183
+ history.pop();
184
+ }
185
+
186
+ // Attempt saving, if quota exceeded, remove oldest until it fits
187
+ let saved = false;
188
+ while (!saved && history.length > 0) {
189
+ try {
190
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
191
+ saved = true;
192
+ } catch (e) {
193
+ history.pop(); // Remove oldest
194
+ if (history.length === 0) {
195
+ console.warn('History storage completely full.');
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ } catch(e) {
201
+ console.warn('Could not save to history', e);
202
+ }
203
+ }
204
+
205
+ function deleteHistoryItem(id) {
206
+ try {
207
+ const history = getHistory();
208
+ const itemToDelete = history.find(h => h.id === id);
209
+ if (!itemToDelete) return;
210
+
211
+ // Check if this item is currently active in the dashboard
212
+ const currentData = loadData();
213
+ if (currentData && currentData.meta && itemToDelete.data &&
214
+ currentData.meta.filename === itemToDelete.data.meta.filename &&
215
+ currentData.meta.processedAt === itemToDelete.data.meta.processedAt) {
216
+ clearData();
217
+ }
218
+
219
+ const newHistory = history.filter(h => h.id !== id);
220
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(newHistory));
221
+ } catch(e) {}
222
+ }
223
+
224
+ function clearData() {
225
+ try {
226
+ localStorage.removeItem(STORE_KEY);
227
+ window._sentimeterData = null;
228
+ } catch(e) {}
229
+ }
230
+
231
+ function loadHistoryItem(id) {
232
+ const history = getHistory();
233
+ const item = history.find(h => h.id === id);
234
+ if (item && item.data) {
235
+ saveData(item.data.rows, item.data.meta);
236
+ return true;
237
+ }
238
+ return false;
239
+ }
240
+
241
+
242
+ // ─── PARSE & PROCESS CSV (IndoBERT) ─────────────────
243
+ /**
244
+ * Process CSV text: parse, clean, and classify sentiment via IndoBERT API.
245
+ * This is an async function that sends texts in batches to the backend.
246
+ * @param {string} csvText - Raw CSV text content
247
+ * @param {function} onProgress - Progress callback(done, total)
248
+ * @returns {Promise<{rows: Array, meta: Object}|null>} - null if API error
249
+ */
250
+ async function processCSV(csvText, onProgress) {
251
+ // 1. First check if model is healthy
252
+ const health = await checkModelHealth();
253
+ if (!health.ok) {
254
+ throw new Error(health.error);
255
+ }
256
+
257
+ // 2. Parse CSV
258
+ const result = Papa.parse(csvText, { header:true, skipEmptyLines:true });
259
+ const raw = result.data;
260
+ if (!raw.length) return { rows: [], meta: {} };
261
+
262
+ const TEXT_COLS = ['full_text','text','tweet','content','teks','body'];
263
+ const textCol = TEXT_COLS.find(c => c in raw[0]) || Object.keys(raw[0])[0];
264
+
265
+ // 3. Build preliminary rows (without sentiment) and collect texts to classify
266
+ const preRows = [];
267
+ const textsToClassify = [];
268
+ const cleanedTexts = [];
269
+
270
+ for (let i = 0; i < raw.length; i++) {
271
+ const r = raw[i];
272
+ const rawTxt = (r[textCol]||'').trim();
273
+ if (!rawTxt) continue;
274
+ const cleaned = cleanText(rawTxt);
275
+ const fav = parseInt(r.favorite_count)||0;
276
+ const rt = parseInt(r.retweet_count)||0;
277
+ const rep = parseInt(r.reply_count)||0;
278
+ const qot = parseInt(r.quote_count)||0;
279
+ const media_url = r.media_url || r.media_url_https || r.photo_url || r.image_url || '';
280
+ const tweetId = r.id_str || r.tweet_id || r.id || r.rest_id || '';
281
+
282
+ preRows.push({
283
+ tweetId,
284
+ raw: rawTxt,
285
+ cleaned,
286
+ username: r.username||r.user_screen_name||'—',
287
+ location: (r.location||'').trim()||'—',
288
+ date: r.created_at||'',
289
+ lang: r.lang||'in',
290
+ fav, rt, rep, qot,
291
+ engagement: fav + rt + rep + qot,
292
+ media_url,
293
+ wordsBefore: rawTxt.split(/\s+/).length,
294
+ wordsAfter: cleaned.split(/\s+/).filter(Boolean).length,
295
+ });
296
+ // Send original text to model for best accuracy
297
+ textsToClassify.push(rawTxt);
298
+ cleanedTexts.push(cleaned);
299
+ }
300
+
301
+ if (preRows.length === 0) return { rows: [], meta: {} };
302
+
303
+ // 4. Send texts in batches to IndoBERT API
304
+ const total = textsToClassify.length;
305
+ const sentimentResults = [];
306
+ let processed = 0;
307
+
308
+ for (let i = 0; i < total; i += INDOBERT_BATCH_SIZE) {
309
+ const batch = textsToClassify.slice(i, i + INDOBERT_BATCH_SIZE);
310
+ const batchResults = await classifyBatch(batch);
311
+ sentimentResults.push(...batchResults);
312
+ processed += batch.length;
313
+ if (onProgress) onProgress(processed, total);
314
+ // Small yield to allow UI updates
315
+ await new Promise(r => setTimeout(r, 10));
316
+ }
317
+
318
+ // 5. Merge sentiment results into rows
319
+ const rows = preRows.map((r, idx) => ({
320
+ id: idx + 1,
321
+ ...r,
322
+ sentiment: sentimentResults[idx].label,
323
+ confidence: sentimentResults[idx].score,
324
+ }));
325
+
326
+ // Meta
327
+ const dates = rows.map(r=>r.date).filter(Boolean).sort();
328
+ const meta = {
329
+ totalRows: rows.length,
330
+ filename: window._uploadedFilename || 'data.csv',
331
+ dateMin: dates[0]||'',
332
+ dateMax: dates[dates.length-1]||'',
333
+ processedAt: new Date().toISOString(),
334
+ };
335
+ return { rows, meta };
336
+ }
337
+
338
+ // ─── THEME MANAGER ──────────────────────────────────
339
+ const THEME_KEY = 'sentimeter_theme';
340
+
341
+ function applyTheme(theme) {
342
+ document.documentElement.setAttribute('data-theme', theme);
343
+ localStorage.setItem(THEME_KEY, theme);
344
+ const btn = document.getElementById('themeToggleTrack');
345
+ if (btn) btn.classList.toggle('on', theme === 'light');
346
+ // Update Chart.js colors if charts exist
347
+ if (window.chartInstances) {
348
+ const gridCol = theme === 'light' ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.05)';
349
+ const textCol = theme === 'light' ? '#4a5568' : '#525b72';
350
+ if (typeof Chart !== 'undefined') {
351
+ Chart.defaults.color = textCol;
352
+ }
353
+ }
354
+ }
355
+
356
+ function initTheme() {
357
+ const saved = localStorage.getItem(THEME_KEY) || 'dark';
358
+ document.documentElement.setAttribute('data-theme', saved);
359
+ }
360
+
361
+ // Apply theme IMMEDIATELY before any paint
362
+ initTheme();
363
+
364
+ // ─── SVG ICONS ───────────────────────────────
365
+ const I = {
366
+ upload: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
367
+ intro: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`,
368
+ dash: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>`,
369
+ chart: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/></svg>`,
370
+ tweets: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
371
+ table: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>`,
372
+ lab: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg>`,
373
+ sun: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>`,
374
+ moon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`,
375
+ collapse: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>`,
376
+ expand: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>`,
377
+ check: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
378
+ alert: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
379
+ menu: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`,
380
+ close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
381
+ history: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>`,
382
+ heart: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>`
383
+ };
384
+
385
+ // ─── SIDEBAR INJECTOR ───────────────────────────────
386
+ const NAV_ITEMS = [
387
+ { href:'index.html', label:'Pengenalan', id:'nav-intro', icon: I.intro, alwaysUnlocked: true },
388
+ { href:'upload.html', label:'Upload Data', id:'nav-upload', icon: I.upload, alwaysUnlocked: true },
389
+ { href:'dashboard.html', label:'Dashboard', id:'nav-dashboard', icon: I.dash },
390
+ { href:'analytics.html', label:'Analytics', id:'nav-analytics', icon: I.chart },
391
+ { href:'tweets.html', label:'Tweet List', id:'nav-tweets', icon: I.tweets },
392
+ { href:'data.html', label:'Data & Tabel', id:'nav-data', icon: I.table },
393
+ { href:'cleaning.html', label:'Cleaning Lab', id:'nav-cleaning', icon: I.lab },
394
+ { href:'history.html', label:'Riwayat Analisis', id:'nav-history', icon: I.history, alwaysUnlocked: true },
395
+ { href:'support.html', label:'Dukungan', id:'nav-support', icon: I.heart, alwaysUnlocked: true },
396
+ ];
397
+
398
+ function injectLayout(activePage) {
399
+ const hasD = hasData();
400
+ const curTheme = localStorage.getItem(THEME_KEY) || 'dark';
401
+ const isLight = curTheme === 'light';
402
+
403
+ const isSidebarCollapsed = localStorage.getItem('sentimeter_sidebar') === 'collapsed';
404
+ if (isSidebarCollapsed) document.body.classList.add('sidebar-collapsed');
405
+
406
+ const navHTML = NAV_ITEMS.map(n => {
407
+ const isActive = n.id === activePage;
408
+ const isLocked = !n.alwaysUnlocked && n.id !== 'nav-upload' && !hasD;
409
+ return `<a href="${isLocked?'#':n.href}"
410
+ class="nav-item${isActive?' active':''}${isLocked?' locked':''}"
411
+ ${isLocked?'title="Upload data terlebih dahulu"':''} title="${n.label}">
412
+ <span class="nav-icon">${n.icon}</span>
413
+ <span class="nav-label">${n.label}</span>
414
+ ${isLocked?'<span class="nav-lock">—</span>':''}
415
+ </a>`;
416
+ }).join('');
417
+
418
+ const sidebar = document.getElementById('sidebar');
419
+ if (sidebar) {
420
+ sidebar.innerHTML = `
421
+ <div class="sidebar-brand">
422
+ <div class="brand-mark"></div>
423
+ <div class="brand-text-wrap">
424
+ <div class="brand-name">SentiMeter</div>
425
+ <div class="brand-sub">IndoBERT · ID</div>
426
+ </div>
427
+ </div>
428
+ <nav class="sidebar-nav">
429
+ <div class="nav-section-label">Navigasi</div>
430
+ ${navHTML}
431
+ </nav>
432
+ <div class="sidebar-footer">
433
+ <button class="theme-toggle" id="themeToggleBtn" title="Ganti tema">
434
+ <span class="theme-icon-wrap" style="display:flex;align-items:center;gap:10px">
435
+ <span class="nav-icon" id="themeIcon">${isLight ? I.sun : I.moon}</span>
436
+ <span class="theme-text">${isLight ? 'Light Mode' : 'Dark Mode'}</span>
437
+ </span>
438
+ <div class="toggle-track${isLight?' on':''}" id="themeToggleTrack">
439
+ <div class="toggle-thumb"></div>
440
+ </div>
441
+ </button>
442
+ <button class="theme-toggle sidebar-toggle-btn" style="margin-top:6px" id="sidebarToggleBtn" title="Toggle Sidebar">
443
+ <span class="theme-icon-wrap" style="display:flex;align-items:center;gap:10px">
444
+ <span class="nav-icon" id="collapseIcon" style="border:none;background:transparent;margin:0">${isSidebarCollapsed ? I.expand : I.collapse}</span>
445
+ <span class="theme-text">Sembunyikan Menu</span>
446
+ </span>
447
+ </button>
448
+ <div style="margin-top:8px">
449
+ ${hasD ? `<div class="data-badge" title="Data dimuat">
450
+ <span class="badge-icon">${I.check}</span>
451
+ <span class="badge-text">Data dimuat</span>
452
+ </div>`
453
+ : `<div class="data-badge no-data" title="Belum ada data">
454
+ <span class="badge-icon">${I.alert}</span>
455
+ <span class="badge-text">Belum ada data</span>
456
+ </div>`}
457
+ </div>
458
+ </div>
459
+ `;
460
+
461
+ // Inject Mobile Menu Trigger to Topbar
462
+ const topbar = document.querySelector('.topbar');
463
+ if (topbar && !document.getElementById('mobileMenuBtn')) {
464
+ const menuBtn = document.createElement('button');
465
+ menuBtn.id = 'mobileMenuBtn';
466
+ menuBtn.className = 'mobile-menu-btn';
467
+ menuBtn.innerHTML = I.menu;
468
+ topbar.prepend(menuBtn);
469
+
470
+ menuBtn.addEventListener('click', () => {
471
+ document.body.classList.add('sidebar-mobile-open');
472
+ });
473
+ }
474
+
475
+ // Inject Overlay
476
+ if (!document.getElementById('sidebarOverlay')) {
477
+ const overlay = document.createElement('div');
478
+ overlay.id = 'sidebarOverlay';
479
+ overlay.className = 'sidebar-overlay';
480
+ document.body.appendChild(overlay);
481
+ overlay.addEventListener('click', () => {
482
+ document.body.classList.remove('sidebar-mobile-open');
483
+ });
484
+ }
485
+
486
+ document.getElementById('themeToggleBtn').addEventListener('click', () => {
487
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
488
+ const next = current === 'dark' ? 'light' : 'dark';
489
+ applyTheme(next);
490
+ const spanText = document.querySelector('#themeToggleBtn .theme-text');
491
+ const iconWrap = document.querySelector('#themeIcon');
492
+ if (spanText) spanText.textContent = next === 'light' ? 'Light Mode' : 'Dark Mode';
493
+ if (iconWrap) iconWrap.innerHTML = next === 'light' ? I.sun : I.moon;
494
+ });
495
+
496
+ document.getElementById('sidebarToggleBtn').addEventListener('click', () => {
497
+ const isCollapsed = document.body.classList.toggle('sidebar-collapsed');
498
+ localStorage.setItem('sentimeter_sidebar', isCollapsed ? 'collapsed' : 'expanded');
499
+ const iconWrap = document.querySelector('#collapseIcon');
500
+ if (iconWrap) iconWrap.innerHTML = isCollapsed ? I.expand : I.collapse;
501
+ });
502
+ }
503
+
504
+ // Inject Toast Container
505
+ if (!document.getElementById('toastContainer')) {
506
+ const toastCont = document.createElement('div');
507
+ toastCont.id = 'toastContainer';
508
+ toastCont.className = 'toast-container';
509
+ document.body.appendChild(toastCont);
510
+ }
511
+ }
512
+
513
+ // ─── CHART DEFAULTS ─────────────────────────────────
514
+ function setChartDefaults() {
515
+ if (typeof Chart === 'undefined') return;
516
+ Chart.defaults.color = '#525b72';
517
+ Chart.defaults.font.family = "'Inter', sans-serif";
518
+ Chart.defaults.font.size = 11;
519
+ Chart.defaults.plugins.tooltip.padding = 10;
520
+ Chart.defaults.plugins.tooltip.cornerRadius = 8;
521
+ Chart.defaults.plugins.tooltip.titleFont = { weight:'600' };
522
+ }
523
+
524
+ // ─── COLOR PALETTE ───────────────────────────────────
525
+ const C = {
526
+ pos:'#34d399', posDim:'rgba(52,211,153,0.15)', posMid:'rgba(52,211,153,0.6)',
527
+ neg:'#f87171', negDim:'rgba(248,113,113,0.15)', negMid:'rgba(248,113,113,0.6)',
528
+ neu:'#fbbf24', neuDim:'rgba(251,191,36,0.15)', neuMid:'rgba(251,191,36,0.6)',
529
+ a1:'#6c8fff', a1d:'rgba(108,143,255,0.15)',
530
+ a2:'#a78bfa', a2d:'rgba(167,139,250,0.15)',
531
+ a3:'#60d9f9', a3d:'rgba(96,217,249,0.15)',
532
+ a4:'#f472b6', a4d:'rgba(244,114,182,0.15)',
533
+ a5:'#fb923c', a5d:'rgba(251,146,60,0.15)',
534
+ palette: ['#6c8fff','#a78bfa','#60d9f9','#f472b6','#fb923c','#34d399','#f87171','#fbbf24'],
535
+ };
536
+
537
+ // grid line shorthand
538
+ const gridColor = 'rgba(255,255,255,0.05)';
539
+
540
+ // destroy helper
541
+ const chartInstances = {};
542
+ function mkChart(id, config) {
543
+ if (chartInstances[id]) { chartInstances[id].destroy(); }
544
+ const ctx = document.getElementById(id);
545
+ if (!ctx) return;
546
+ chartInstances[id] = new Chart(ctx, config);
547
+ return chartInstances[id];
548
+ }
549
+
550
+ // ─── CUSTOM SELECT COMPONENT ────────────────────────
551
+ function initCustomSelect(sel, opts = {}) {
552
+ if (!sel || sel._csdInit) return;
553
+ sel._csdInit = true;
554
+
555
+ const compact = opts.compact || false;
556
+ const showDots = opts.showDots || null; // { value: cssColor }
557
+
558
+ // Build wrapper
559
+ const wrap = document.createElement('div');
560
+ wrap.className = 'csd-wrap' + (compact ? ' csd-compact' : '');
561
+
562
+ // Build trigger
563
+ const trigger = document.createElement('button');
564
+ trigger.type = 'button';
565
+ trigger.className = 'csd-trigger';
566
+
567
+ const labelEl = document.createElement('span');
568
+ labelEl.className = 'csd-label';
569
+
570
+ const chevron = document.createElementNS('http://www.w3.org/2000/svg','svg');
571
+ chevron.setAttribute('viewBox','0 0 24 24');
572
+ chevron.setAttribute('fill','none');
573
+ chevron.setAttribute('stroke','currentColor');
574
+ chevron.setAttribute('stroke-width','2.2');
575
+ chevron.setAttribute('stroke-linecap','round');
576
+ chevron.setAttribute('stroke-linejoin','round');
577
+ chevron.classList.add('csd-chevron');
578
+ chevron.innerHTML = '<polyline points="6 9 12 15 18 9"/>';
579
+
580
+ trigger.appendChild(labelEl);
581
+ trigger.appendChild(chevron);
582
+
583
+ // Build panel
584
+ const panel = document.createElement('div');
585
+ panel.className = 'csd-panel';
586
+ panel.style.display = 'none';
587
+
588
+ const searchWrap = document.createElement('div');
589
+ searchWrap.className = 'csd-search-wrap';
590
+ const searchInput = document.createElement('input');
591
+ searchInput.className = 'csd-search';
592
+ searchInput.type = 'text';
593
+ searchInput.placeholder = 'Cari...';
594
+ searchInput.autocomplete = 'off';
595
+ searchWrap.appendChild(searchInput);
596
+
597
+ const list = document.createElement('div');
598
+ list.className = 'csd-list';
599
+
600
+ panel.appendChild(searchWrap);
601
+ panel.appendChild(list);
602
+
603
+ wrap.appendChild(trigger);
604
+ wrap.appendChild(panel);
605
+
606
+ // Insert before native select, hide it
607
+ sel.parentNode.insertBefore(wrap, sel);
608
+ sel.style.display = 'none';
609
+ wrap.appendChild(sel); // keep in DOM for value access
610
+
611
+ function updateLabel() {
612
+ const cur = sel.options[sel.selectedIndex];
613
+ labelEl.textContent = cur ? cur.text : '—';
614
+ }
615
+
616
+ function renderList(query) {
617
+ const q = (query || '').toLowerCase().trim();
618
+ list.innerHTML = '';
619
+ let count = 0;
620
+ Array.from(sel.options).forEach(o => {
621
+ if (q && !o.text.toLowerCase().includes(q)) return;
622
+ count++;
623
+ const item = document.createElement('div');
624
+ item.className = 'csd-option' + (o.selected ? ' selected' : '');
625
+ item.dataset.value = o.value;
626
+
627
+ if (showDots && showDots[o.value]) {
628
+ const dot = document.createElement('span');
629
+ dot.className = 'csd-dot';
630
+ dot.style.background = showDots[o.value];
631
+ item.appendChild(dot);
632
+ }
633
+
634
+ const txt = document.createElement('span');
635
+ txt.textContent = o.text;
636
+ item.appendChild(txt);
637
+
638
+ // Check SVG
639
+ const check = document.createElementNS('http://www.w3.org/2000/svg','svg');
640
+ check.setAttribute('viewBox','0 0 24 24');
641
+ check.setAttribute('fill','none');
642
+ check.setAttribute('stroke','currentColor');
643
+ check.setAttribute('stroke-width','3');
644
+ check.setAttribute('stroke-linecap','round');
645
+ check.setAttribute('stroke-linejoin','round');
646
+ check.classList.add('csd-check');
647
+ check.innerHTML = '<polyline points="20 6 9 17 4 12"/>';
648
+ item.appendChild(check);
649
+
650
+ item.addEventListener('mousedown', (e) => {
651
+ e.preventDefault();
652
+ sel.value = o.value;
653
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
654
+ updateLabel();
655
+ closePanel();
656
+ });
657
+ list.appendChild(item);
658
+ });
659
+ if (count === 0) {
660
+ const em = document.createElement('div');
661
+ em.className = 'csd-empty';
662
+ em.textContent = 'Tidak ada hasil';
663
+ list.appendChild(em);
664
+ }
665
+ }
666
+
667
+ function openPanel() {
668
+ panel.style.display = 'block';
669
+ trigger.classList.add('open');
670
+ searchInput.value = '';
671
+ renderList('');
672
+ const selItem = list.querySelector('.selected');
673
+ if (selItem) selItem.scrollIntoView({ block: 'nearest' });
674
+ if (!compact) requestAnimationFrame(() => searchInput.focus());
675
+ }
676
+
677
+ function closePanel() {
678
+ panel.style.display = 'none';
679
+ trigger.classList.remove('open');
680
+ }
681
+
682
+ trigger.addEventListener('click', (e) => {
683
+ e.stopPropagation();
684
+ panel.style.display === 'none' ? openPanel() : closePanel();
685
+ });
686
+
687
+ searchInput.addEventListener('input', () => renderList(searchInput.value));
688
+ searchInput.addEventListener('click', e => e.stopPropagation());
689
+ panel.addEventListener('click', e => e.stopPropagation());
690
+
691
+ document.addEventListener('click', (e) => {
692
+ if (!wrap.contains(e.target)) closePanel();
693
+ });
694
+
695
+ trigger.addEventListener('keydown', (e) => {
696
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); panel.style.display === 'none' ? openPanel() : closePanel(); }
697
+ if (e.key === 'Escape') closePanel();
698
+ });
699
+
700
+ // Sync when value changed externally (e.g. reset)
701
+ const origChange = sel.onchange;
702
+ sel.addEventListener('_csdRefresh', updateLabel);
703
+
704
+ updateLabel();
705
+ return { open: openPanel, close: closePanel, refresh: updateLabel };
706
+ }
707
+
708
+ // ─── CUSTOM NUMBER INPUT ────────────────────────────
709
+ function initCustomNumber(inp) {
710
+ if (!inp || inp._cniInit) return;
711
+ inp._cniInit = true;
712
+
713
+ const wrap = document.createElement('div');
714
+ wrap.className = 'cni-wrap';
715
+ inp.parentNode.insertBefore(wrap, inp);
716
+ wrap.appendChild(inp);
717
+
718
+ const arrows = document.createElement('div');
719
+ arrows.className = 'cni-arrows';
720
+
721
+ const btnUp = document.createElement('button');
722
+ btnUp.type = 'button';
723
+ btnUp.className = 'cni-btn';
724
+ // Up chevron
725
+ btnUp.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`;
726
+
727
+ const btnDown = document.createElement('button');
728
+ btnDown.type = 'button';
729
+ btnDown.className = 'cni-btn';
730
+ // Down chevron
731
+ btnDown.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
732
+
733
+ arrows.appendChild(btnUp);
734
+ arrows.appendChild(btnDown);
735
+ wrap.appendChild(arrows);
736
+
737
+ function step(dir) {
738
+ let val = parseFloat(inp.value) || 0;
739
+ let s = parseFloat(inp.step) || 1;
740
+ let min = inp.hasAttribute('min') ? parseFloat(inp.min) : -Infinity;
741
+ let max = inp.hasAttribute('max') ? parseFloat(inp.max) : Infinity;
742
+
743
+ val += (dir * s);
744
+ if (val < min) val = min;
745
+ if (val > max) val = max;
746
+
747
+ // Round to precision to avoid JS float precision issues (like .999999999)
748
+ const decimals = (String(s).split('.')[1] || '').length;
749
+ inp.value = decimals ? val.toFixed(decimals) : String(Math.round(val));
750
+
751
+ inp.dispatchEvent(new Event('input', { bubbles: true }));
752
+ inp.dispatchEvent(new Event('change', { bubbles: true }));
753
+ }
754
+
755
+ btnUp.addEventListener('click', (e) => { e.preventDefault(); step(1); inp.focus(); });
756
+ btnDown.addEventListener('click', (e) => { e.preventDefault(); step(-1); inp.focus(); });
757
+ }
758
+
759
+ // expose globals
760
+ window.SM = {
761
+ STOPWORDS, PIPELINE_STEPS,
762
+ cleanText, cleanSteps, cleanStep,
763
+ checkModelHealth, classifyBatch,
764
+ fmt, esc, avg,
765
+ saveData, loadData, hasData, clearData, processCSV,
766
+ getHistory, saveToHistory, deleteHistoryItem, loadHistoryItem,
767
+ injectLayout, setChartDefaults, mkChart, C, gridColor,
768
+ initCustomSelect, initCustomNumber,
769
+ showToast: function(message, type = 'success') {
770
+ const container = document.getElementById('toastContainer');
771
+ if (!container) return;
772
+
773
+ const toast = document.createElement('div');
774
+ toast.className = `toast toast-${type} toast-enter`;
775
+
776
+ let icon = type === 'error' ? I.alert : I.check;
777
+
778
+ toast.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-message">${message}</span>`;
779
+ container.appendChild(toast);
780
+
781
+ void toast.offsetWidth;
782
+ toast.classList.remove('toast-enter');
783
+
784
+ setTimeout(() => {
785
+ toast.classList.add('toast-exit');
786
+ toast.addEventListener('transitionend', () => toast.remove());
787
+ }, 3000);
788
+ },
789
+ showModal: function(opts) {
790
+ const {
791
+ title, message, type = 'success',
792
+ confirmText = 'OK, Lanjutkan',
793
+ onConfirm,
794
+ showCancel = false,
795
+ cancelText = 'Batal',
796
+ onCancel,
797
+ isDanger = false
798
+ } = opts;
799
+
800
+ const overlay = document.createElement('div');
801
+ overlay.className = 'modal-overlay';
802
+
803
+ const modal = document.createElement('div');
804
+ modal.className = `modal-box modal-${type}`;
805
+
806
+ let icon = type === 'error' ? I.alert : I.check;
807
+
808
+ let btnsHTML = `<div class="modal-btns">
809
+ ${showCancel ? `<button class="btn modal-btn modal-btn-cancel">${cancelText}</button>` : ''}
810
+ <button class="btn modal-btn ${isDanger ? 'modal-btn-danger' : 'btn-primary'}">${confirmText}</button>
811
+ </div>`;
812
+
813
+ modal.innerHTML = `
814
+ <div class="modal-icon-wrap">${icon}</div>
815
+ <h3 class="modal-title">${title}</h3>
816
+ <p class="modal-desc">${message}</p>
817
+ ${btnsHTML}
818
+ `;
819
+
820
+ overlay.appendChild(modal);
821
+ document.body.appendChild(overlay);
822
+
823
+ // trigger animation
824
+ void overlay.offsetWidth;
825
+ overlay.classList.add('active');
826
+
827
+ // bind confirm
828
+ const btnConfirm = modal.querySelector(isDanger ? '.modal-btn-danger' : '.btn-primary');
829
+ btnConfirm.addEventListener('click', () => {
830
+ overlay.classList.remove('active');
831
+ setTimeout(() => {
832
+ overlay.remove();
833
+ if (onConfirm) onConfirm();
834
+ }, 300);
835
+ });
836
+
837
+ if (showCancel) {
838
+ const btnCancel = modal.querySelector('.modal-btn-cancel');
839
+ btnCancel.addEventListener('click', () => {
840
+ overlay.classList.remove('active');
841
+ setTimeout(() => {
842
+ overlay.remove();
843
+ if (onCancel) onCancel();
844
+ }, 300);
845
+ });
846
+ }
847
+ }
848
+ };
849
+
850
+ // Auto-trigger persistence modal check on page load
851
+ document.addEventListener('DOMContentLoaded', () => {
852
+ setTimeout(() => {
853
+ if (window.SM && window.SM.hasData()) {
854
+ let isReload = false;
855
+
856
+ // 1. Basic modern performance check (works on desktop)
857
+ if (window.performance) {
858
+ const navEntries = performance.getEntriesByType('navigation');
859
+ if (navEntries.length > 0 && navEntries[0].type === 'reload') {
860
+ isReload = true;
861
+ } else if (performance.navigation && performance.navigation.type === 1) {
862
+ isReload = true;
863
+ }
864
+ }
865
+
866
+ // 2. Bulletproof Mobile Reload Detection (Pull-to-refresh bypasses performance API on iOS/Safari)
867
+ // When a user clicks a link, we set a flag. If the flag isn't there on load, it was a direct hit/refresh.
868
+ if (!sessionStorage.getItem('sm_internal_nav')) {
869
+ // Only consider it a reload if they've been here before in this tab (not the very first click from Twitter)
870
+ if (sessionStorage.getItem('sm_visited')) {
871
+ isReload = true;
872
+ }
873
+ }
874
+
875
+ // Mark that this tab session has visited the site
876
+ sessionStorage.setItem('sm_visited', '1');
877
+ // Clear the internal nav flag so the NEXT load is assumed a refresh UNLESS a link is clicked
878
+ sessionStorage.removeItem('sm_internal_nav');
879
+
880
+ if (isReload) {
881
+ window.SM.showModal({
882
+ title: 'Data Tersimpan Secara Lokal',
883
+ message: 'Sistem menemukan data dari sesi Anda sebelumnya. Data ini disimpan dengan aman di <i>Local Storage</i> perangkat Anda dan <b>tidak diunggah ke server mana pun</b>.<br><br><span style="color:var(--neg);font-size:13px"><b style="font-weight:700">Catatan Penting:</b> Menghapus <i>cache</i> atau <i>history browser</i> akan menghapus data ini secara permanen.</span>',
884
+ type: 'success'
885
+ });
886
+ }
887
+ }
888
+ }, 300);
889
+ });
890
+
891
+ // Attach listener to all internal links to mark navigation vs refresh
892
+ document.addEventListener('click', (e) => {
893
+ const link = e.target.closest('a');
894
+ if (link && link.href && link.hostname === window.location.hostname) {
895
+ sessionStorage.setItem('sm_internal_nav', '1');
896
+ }
897
+ });
js/support.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ document.addEventListener('DOMContentLoaded', () => {
4
+ // Inject layout
5
+ SM.injectLayout('nav-support');
6
+ });
js/tweets.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const _store = SM.loadData();
4
+ if (!_store) {
5
+ window.location.replace('upload.html');
6
+ throw new Error('No data — redirecting');
7
+ }
8
+
9
+ SM.injectLayout('nav-tweets');
10
+
11
+ document.addEventListener('DOMContentLoaded', function () {
12
+ const { rows, meta } = _store;
13
+ document.getElementById('topbarMeta').textContent =
14
+ `${meta.filename} — ${rows.length} tweets`;
15
+
16
+ let pageSize = 20;
17
+ let currentPage = 1;
18
+ let activeFilter = 'all';
19
+ let searchQuery = '';
20
+ let sortMode = 'default';
21
+
22
+ const pos = rows.filter(r => r.sentiment === 'Positif').length;
23
+ const neg = rows.filter(r => r.sentiment === 'Negatif').length;
24
+ const neu = rows.filter(r => r.sentiment === 'Netral').length;
25
+
26
+ // Update tab counts
27
+ document.getElementById('cntPos').textContent = pos;
28
+ document.getElementById('cntNeg').textContent = neg;
29
+ document.getElementById('cntNeu').textContent = neu;
30
+
31
+ function getFiltered() {
32
+ let result = rows;
33
+ if (activeFilter !== 'all') result = result.filter(r => r.sentiment === activeFilter);
34
+ if (searchQuery) {
35
+ const q = searchQuery.toLowerCase();
36
+ result = result.filter(r =>
37
+ (r.raw||'').toLowerCase().includes(q) ||
38
+ (r.username||'').toLowerCase().includes(q) ||
39
+ (r.location||'').toLowerCase().includes(q)
40
+ );
41
+ }
42
+ // Sort
43
+ if (sortMode === 'conf-desc') result = [...result].sort((a,b) => b.confidence - a.confidence);
44
+ else if (sortMode === 'conf-asc') result = [...result].sort((a,b) => a.confidence - b.confidence);
45
+ else if (sortMode === 'engage-desc') result = [...result].sort((a,b) => (b.engagement||0) - (a.engagement||0));
46
+ else if (sortMode === 'date-desc') result = [...result].sort((a,b) => (b.date||'').localeCompare(a.date||''));
47
+ else if (sortMode === 'date-asc') result = [...result].sort((a,b) => (a.date||'').localeCompare(b.date||''));
48
+ return result;
49
+ }
50
+
51
+ function formatDate(ds) {
52
+ if (!ds) return '';
53
+ const d = new Date(ds);
54
+ if (isNaN(d.getTime())) return ds.slice(0,16).replace('T',' ');
55
+ return d.toLocaleDateString('id-ID', {day:'numeric',month:'short',year:'numeric'}) +
56
+ ' · ' + d.toLocaleTimeString('id-ID', {hour:'2-digit',minute:'2-digit'});
57
+ }
58
+
59
+ function initials(name) {
60
+ if (!name || name === '—') return '?';
61
+ return name.replace('@','').split(/[\s_]/)[0].slice(0,2).toUpperCase();
62
+ }
63
+
64
+ function avatarColor(name) {
65
+ const colors = ['#6c8fff','#a78bfa','#34d399','#f472b6','#fb923c','#60d9f9','#f87171','#fbbf24'];
66
+ let h = 0;
67
+ for (let c of (name||'')) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
68
+ return colors[Math.abs(h) % colors.length];
69
+ }
70
+
71
+ function renderHashtags(text) {
72
+ return (text.match(/#(\w+)/g) || []).slice(0,5)
73
+ .map(t => `<span class="tl-hashtag">${SM.esc(t)}</span>`).join('');
74
+ }
75
+
76
+ function highlightText(text, q) {
77
+ if (!q) return SM.esc(text);
78
+ const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
+ return SM.esc(text).replace(new RegExp(`(${escaped})`, 'gi'),
80
+ '<mark class="tl-highlight">$1</mark>');
81
+ }
82
+
83
+ function sentimentClass(s) {
84
+ return s === 'Positif' ? 'pos' : s === 'Negatif' ? 'neg' : 'neu';
85
+ }
86
+ function sentimentBadgeClass(s) {
87
+ return s === 'Positif' ? 'badge-pos' : s === 'Negatif' ? 'badge-neg' : 'badge-neu';
88
+ }
89
+
90
+ function renderConfBar(conf, sent) {
91
+ const pct = (conf * 100).toFixed(0);
92
+ const cls = sent === 'Positif' ? 'conf-fill-pos' : sent === 'Negatif' ? 'conf-fill-neg' : 'conf-fill-neu';
93
+ return `<div class="tl-conf-wrap">
94
+ <span class="tl-conf-val">${(conf*100).toFixed(1)}%</span>
95
+ <div class="tl-conf-track">
96
+ <div class="tl-conf-fill ${cls}" style="width:${pct}%"></div>
97
+ </div>
98
+ </div>`;
99
+ }
100
+
101
+ function renderCard(r) {
102
+ const sc = sentimentClass(r.sentiment);
103
+ const sbc = sentimentBadgeClass(r.sentiment);
104
+ const col = avatarColor(r.username);
105
+ const init = initials(r.username);
106
+ const tags = renderHashtags(r.raw);
107
+ const hasMedia = r.media_url || r.photo_url || r.image_url;
108
+ const mediaURL = r.media_url || r.photo_url || r.image_url || '';
109
+
110
+ return `
111
+ <div class="tl-card tl-card-${sc}">
112
+ <!-- Card Header -->
113
+ <div class="tl-card-header">
114
+ <div class="tl-avatar" style="background:${col}">${init}</div>
115
+ <div class="tl-user-info">
116
+ <div class="tl-user-row">
117
+ <span class="tl-username">@${SM.esc(r.username || '—')}</span>
118
+ <span class="tl-date">${formatDate(r.date)}</span>
119
+ </div>
120
+ ${r.location && r.location !== '—' ? `<span class="tl-location">${SM.esc(r.location)}</span>` : ''}
121
+ </div>
122
+ <div class="tl-header-right">
123
+ <div style="display:flex; gap:6px; align-items:center;">
124
+ <span class="badge ${sbc} tl-sent-badge">${SM.esc(r.sentiment)}</span>
125
+ ${r.tweetId ?
126
+ `<a href="https://twitter.com/${SM.esc(r.username)}/status/${r.tweetId}" target="_blank" rel="noopener noreferrer" class="tl-link-btn" title="Buka di X (Twitter)">
127
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
128
+ </a>` : ''}
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- Tweet Text -->
134
+ <div class="tl-tweet-text">${highlightText(r.raw, searchQuery)}</div>
135
+
136
+ <!-- Hashtags -->
137
+ ${tags ? `<div class="tl-tags-row">${tags}</div>` : ''}
138
+
139
+ <!-- Media (if available) -->
140
+ ${hasMedia ? `<div class="tl-media"><img src="${SM.esc(mediaURL)}" alt="tweet media" class="tl-media-img" loading="lazy" onerror="this.parentElement.style.display='none'" /></div>` : ''}
141
+
142
+ <!-- Footer -->
143
+ <div class="tl-card-footer">
144
+ <div class="tl-engage-row">
145
+ <span class="tl-engage-item" title="Replies">
146
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
147
+ ${r.rep || 0}
148
+ </span>
149
+ <span class="tl-engage-item" title="Retweets">
150
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
151
+ ${r.rt || 0}
152
+ </span>
153
+ <span class="tl-engage-item" title="Likes">
154
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
155
+ ${r.fav || 0}
156
+ </span>
157
+ ${r.qot ? `<span class="tl-engage-item" title="Quotes">
158
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>
159
+ ${r.qot}
160
+ </span>` : ''}
161
+ </div>
162
+ </div>
163
+ </div>`;
164
+ }
165
+
166
+ function render() {
167
+ const filtered = getFiltered();
168
+ const total = filtered.length;
169
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
170
+ currentPage = Math.min(currentPage, totalPages);
171
+
172
+ const start = (currentPage - 1) * pageSize;
173
+ const end = Math.min(start + pageSize, total);
174
+ const page = filtered.slice(start, end);
175
+
176
+ const feed = document.getElementById('tweetFeed');
177
+
178
+ if (!page.length) {
179
+ feed.innerHTML = `
180
+ <div class="empty-state" style="margin-top:20px;border:1px dashed var(--border)">
181
+ <div class="empty-state-title">Tidak ada tweet ditemukan</div>
182
+ <div class="empty-state-desc">Coba ubah filter atau kata kunci pencarian</div>
183
+ </div>`;
184
+ } else {
185
+ feed.innerHTML = page.map(r => renderCard(r)).join('');
186
+ }
187
+
188
+ // Info
189
+ document.getElementById('tweetInfo').textContent =
190
+ total > 0
191
+ ? `Menampilkan ${start + 1}–${end} dari ${total} data (total ${rows.length})`
192
+ : '';
193
+
194
+ // Pagination
195
+ renderPagination(totalPages);
196
+
197
+ // Stats pills
198
+ document.getElementById('tweetStats').innerHTML =
199
+ `<span class="tl-stat-pill" style="background:var(--pos-d);color:var(--pos)">${pos} Positif</span>
200
+ <span class="tl-stat-pill" style="background:var(--neg-d);color:var(--neg)">${neg} Negatif</span>
201
+ <span class="tl-stat-pill" style="background:var(--neu-d);color:var(--neu)">${neu} Netral</span>`;
202
+ }
203
+
204
+ function renderPagination(totalPages) {
205
+ const pg = document.getElementById('tweetPagination');
206
+ if (totalPages <= 1) { pg.innerHTML = ''; return; }
207
+ const range = [];
208
+ range.push(1);
209
+ if (currentPage > 3) range.push('…');
210
+ for (let i = Math.max(2, currentPage-1); i <= Math.min(totalPages-1, currentPage+1); i++) range.push(i);
211
+ if (currentPage < totalPages - 2) range.push('…');
212
+ if (totalPages > 1) range.push(totalPages);
213
+
214
+ pg.innerHTML = `
215
+ <button class="pg-btn" ${currentPage===1?'disabled':''} id="pgPrev" title="Previous">
216
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><polyline points="15 18 9 12 15 6"/></svg>
217
+ </button>
218
+ ${range.map(p => p === '…'
219
+ ? `<span class="pg-btn dots">…</span>`
220
+ : `<button class="pg-btn ${p===currentPage?'active':''}" data-page="${p}">${p}</button>`
221
+ ).join('')}
222
+ <button class="pg-btn" ${currentPage===totalPages?'disabled':''} id="pgNext" title="Next">
223
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><polyline points="9 18 15 12 9 6"/></svg>
224
+ </button>`;
225
+
226
+ pg.querySelectorAll('[data-page]').forEach(btn => {
227
+ btn.addEventListener('click', () => { currentPage = +btn.dataset.page; render(); scrollToFeed(); });
228
+ });
229
+ const prev = pg.querySelector('#pgPrev');
230
+ const next = pg.querySelector('#pgNext');
231
+ if (prev) prev.addEventListener('click', () => { currentPage--; render(); scrollToFeed(); });
232
+ if (next) next.addEventListener('click', () => { currentPage++; render(); scrollToFeed(); });
233
+ }
234
+
235
+ function scrollToFeed() {
236
+ document.getElementById('tweetFeed').scrollIntoView({ behavior:'smooth', block:'start' });
237
+ }
238
+
239
+ // ── Filter tabs ──
240
+ document.querySelectorAll('.tl-tab').forEach(btn => {
241
+ btn.addEventListener('click', () => {
242
+ document.querySelectorAll('.tl-tab').forEach(b => b.classList.remove('active'));
243
+ btn.classList.add('active');
244
+ activeFilter = btn.dataset.filter;
245
+ currentPage = 1;
246
+ render();
247
+ });
248
+ });
249
+
250
+ // ── Search ──
251
+ let searchTimer;
252
+ document.getElementById('tweetSearch').addEventListener('input', e => {
253
+ clearTimeout(searchTimer);
254
+ searchTimer = setTimeout(() => {
255
+ searchQuery = e.target.value.trim();
256
+ document.getElementById('searchClear').style.display = searchQuery ? 'flex' : 'none';
257
+ currentPage = 1;
258
+ render();
259
+ }, 220);
260
+ });
261
+ document.getElementById('searchClear').addEventListener('click', () => {
262
+ document.getElementById('tweetSearch').value = '';
263
+ searchQuery = '';
264
+ document.getElementById('searchClear').style.display = 'none';
265
+ currentPage = 1;
266
+ render();
267
+ });
268
+
269
+ // ── Sort ──
270
+ document.getElementById('sortSel').addEventListener('change', e => {
271
+ sortMode = e.target.value;
272
+ currentPage = 1;
273
+ render();
274
+ });
275
+
276
+ // Initial render
277
+ render();
278
+
279
+ // ── Custom Dropdowns ──
280
+ SM.initCustomSelect(document.getElementById('sortSel'), { compact: false });
281
+ SM.initCustomSelect(document.getElementById('pageSizeSel'), { compact: true });
282
+
283
+ // ── Page Size Change ──
284
+ document.getElementById('pageSizeSel').addEventListener('change', e => {
285
+ pageSize = parseInt(e.target.value);
286
+ currentPage = 1;
287
+ render();
288
+ });
289
+ });
js/upload.js ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+ SM.injectLayout('nav-upload');
3
+
4
+ const uploadZone = document.getElementById('uploadZone');
5
+ const csvInput = document.getElementById('csvInput');
6
+ let selectedFiles = [];
7
+
8
+ // ── Drag & Drop ──
9
+ uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag'); });
10
+ uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag'));
11
+ uploadZone.addEventListener('drop', e => {
12
+ e.preventDefault(); uploadZone.classList.remove('drag');
13
+ const allFiles = Array.from(e.dataTransfer.files);
14
+ if (!allFiles.length) return;
15
+ const files = allFiles.filter(f => f.name.endsWith('.csv'));
16
+ if (files.length) {
17
+ showFilePreview(files);
18
+ } else {
19
+ SM.showModal({ title: 'Format Tidak Sesuai', message: 'Hanya menerima file .csv', type: 'error' });
20
+ }
21
+ });
22
+ uploadZone.addEventListener('click', e => {
23
+ if (e.target.tagName === 'BUTTON') return;
24
+ csvInput.click();
25
+ });
26
+ csvInput.addEventListener('change', e => {
27
+ const allFiles = Array.from(e.target.files);
28
+ if (!allFiles.length) return;
29
+ const files = allFiles.filter(f => f.name.endsWith('.csv'));
30
+ if (files.length) {
31
+ showFilePreview(files);
32
+ } else {
33
+ SM.showModal({ title: 'Format Tidak Sesuai', message: 'Hanya menerima file dataset dalam format <strong>.csv</strong>.', type: 'error' });
34
+ }
35
+ });
36
+
37
+ function showFilePreview(files) {
38
+ selectedFiles = files;
39
+
40
+ const totalSize = files.reduce((acc, f) => acc + f.size, 0);
41
+
42
+ if (files.length === 1) {
43
+ window._uploadedFilename = files[0].name;
44
+ document.getElementById('fileName').textContent = files[0].name;
45
+ } else {
46
+ window._uploadedFilename = `${files.length}_File_CSV_Gabungan.csv`;
47
+ document.getElementById('fileName').textContent = `${files.length} File CSV Terpilih`;
48
+ }
49
+
50
+ document.getElementById('filePreview').style.display = 'flex';
51
+ document.getElementById('fileMeta').textContent = `${(totalSize/1024).toFixed(1)} KB`;
52
+ }
53
+
54
+ document.getElementById('btnCancel').addEventListener('click', () => {
55
+ selectedFiles = [];
56
+ csvInput.value = '';
57
+ document.getElementById('filePreview').style.display = 'none';
58
+ });
59
+
60
+ document.getElementById('btnAnalyze').addEventListener('click', async () => {
61
+ if (!selectedFiles || selectedFiles.length === 0) return;
62
+
63
+ document.getElementById('filePreview').style.display = 'none';
64
+ document.getElementById('progressWrap').style.display = 'block';
65
+
66
+ const steps = ['Membaca file...','Parsing CSV...','Menjalankan Text Cleaning...',
67
+ 'Tokenisasi IndoBERT...','Klasifikasi Sentimen...','Menyimpan Hasil...','Selesai!'];
68
+
69
+ let combinedText = '';
70
+ for (let i = 0; i < selectedFiles.length; i++) {
71
+ let t = await selectedFiles[i].text();
72
+ // Untuk file kedua dan seterusnya, hapus baris pertama (header csv) agar tidak ikut terproses
73
+ if (i > 0) {
74
+ const firstNewline = t.indexOf('\\n');
75
+ if (firstNewline !== -1) t = t.substring(firstNewline + 1);
76
+ }
77
+ // Gabungkan text, pastikan dipisah enter
78
+ combinedText += t + (t.endsWith('\\n') ? '' : '\\n');
79
+ }
80
+
81
+ const text = combinedText;
82
+
83
+ // Update progress UI step
84
+ document.getElementById('progressStep').textContent = steps[0];
85
+
86
+ // Run in next tick so UI updates first
87
+ await new Promise(r => setTimeout(r, 30));
88
+
89
+ let stepIdx = 0;
90
+ function progress(done, total) {
91
+ const pct = Math.round((done/total)*100);
92
+ document.getElementById('progressBar').style.width = pct + '%';
93
+ document.getElementById('progressPct').textContent = pct + '%';
94
+ const si = Math.min(Math.floor((pct/100)*steps.length), steps.length-1);
95
+ if (si !== stepIdx) { stepIdx = si; document.getElementById('progressStep').textContent = steps[si]; }
96
+ }
97
+
98
+ try {
99
+ // processCSV is now async — calls IndoBERT API in batches
100
+ const result = await SM.processCSV(text, progress);
101
+
102
+ if (!result || !result.rows || !result.rows.length) {
103
+ SM.showModal({
104
+ title: 'Dataset Kosong',
105
+ message: 'Tidak ada data teks yang ditemukan dalam file CSV tersebut.',
106
+ type: 'error'
107
+ });
108
+ document.getElementById('progressWrap').style.display = 'none';
109
+ document.getElementById('filePreview').style.display = 'flex';
110
+ return;
111
+ }
112
+
113
+ const { rows, meta } = result;
114
+
115
+ // Animate to 100%
116
+ document.getElementById('progressBar').style.width = '100%';
117
+ document.getElementById('progressPct').textContent = '100%';
118
+ document.getElementById('progressStep').textContent = 'Selesai! Menyiapkan dashboard...';
119
+
120
+ SM.saveData(rows, meta);
121
+
122
+ // Show center success modal
123
+ setTimeout(() => {
124
+ SM.showModal({
125
+ title: 'Analisis Selesai',
126
+ message: `
127
+ Berhasil memproses <strong>${SM.fmt(rows.length)}</strong> teks dengan model machine learning IndoBERT. Hasil analisis telah siap untuk ditinjau.
128
+ <br><br>
129
+ <span style="font-size:13.5px;color:var(--tx2);display:block;background:var(--bg-card2);padding:16px;border-radius:12px;text-align:left;line-height:1.5;">
130
+ <strong>Peringatan Penting:</strong> Seluruh data sentimen dan riwayat file CSV Anda disimpan secara permanen di memori lokal (<em>Local Storage</em>) peramban web ini untuk menjamin privasi. <strong>Tolong jangan menghapus riwayat peramban atau <em>Cache / Clear Data</em></strong>, karena akan menyebabkan semua data di Riwayat Analisis hilang secara permanen.
131
+ </span>
132
+ `,
133
+ type: 'success',
134
+ onConfirm: () => { window.location.href = 'dashboard.html'; }
135
+ });
136
+ }, 400);
137
+
138
+ } catch (err) {
139
+ // Show error popup when IndoBERT model / API fails
140
+ document.getElementById('progressWrap').style.display = 'none';
141
+ document.getElementById('filePreview').style.display = 'flex';
142
+
143
+ // Reset progress bar
144
+ document.getElementById('progressBar').style.width = '0%';
145
+ document.getElementById('progressPct').textContent = '0%';
146
+ document.getElementById('progressStep').textContent = '';
147
+
148
+ SM.showModal({
149
+ title: 'Gagal Memproses Sentimen',
150
+ message: `
151
+ <strong>Model IndoBERT tidak dapat diakses.</strong><br><br>
152
+ ${SM.esc(err.message || 'Terjadi kesalahan yang tidak diketahui.')}
153
+ <br><br>
154
+ <span style="font-size:13px;color:var(--tx2);display:block;background:var(--bg-card2);padding:14px;border-radius:10px;text-align:left;line-height:1.5;">
155
+ <strong>Solusi:</strong><br>
156
+ 1. Pastikan server Python sudah berjalan (<code>python app.py</code>)<br>
157
+ 2. Pastikan model IndoBERT sudah terunduh dengan benar<br>
158
+ 3. Pastikan dependensi Python sudah terinstall (<code>pip install -r requirements.txt</code>)
159
+ </span>
160
+ `,
161
+ type: 'error'
162
+ });
163
+ }
164
+ });
165
+
166
+ // ── Demo Data Trigger ──
167
+ const btnDemo = document.getElementById('btnDemoData');
168
+ if (btnDemo) {
169
+ btnDemo.addEventListener('click', async () => {
170
+ if (typeof window.DEMO_CSV === 'undefined') {
171
+ SM.showToast('Data demo tidak tersedia.', 'error');
172
+ return;
173
+ }
174
+
175
+ // Set demo filename for meta
176
+ window._uploadedFilename = 'demo_300_tweets.csv';
177
+
178
+ document.getElementById('filePreview').style.display = 'none';
179
+ document.getElementById('progressWrap').style.display = 'block';
180
+
181
+ const steps = ['Membaca data demo...', 'Parsing CSV...', 'Menjalankan Text Cleaning...',
182
+ 'Tokenisasi IndoBERT...', 'Klasifikasi Sentimen...', 'Menyimpan Hasil...', 'Selesai!'];
183
+
184
+ document.getElementById('progressStep').textContent = steps[0];
185
+
186
+ async function progress(done, total) {
187
+ const pct = Math.round((done / total) * 100);
188
+ document.getElementById('progressBar').style.width = pct + '%';
189
+ document.getElementById('progressPct').textContent = pct + '%';
190
+ const si = Math.min(Math.floor((pct / 100) * steps.length), steps.length - 1);
191
+ document.getElementById('progressStep').textContent = steps[si];
192
+ }
193
+
194
+ try {
195
+ const result = await SM.processCSV(window.DEMO_CSV, progress);
196
+ const { rows, meta } = result;
197
+
198
+ document.getElementById('progressBar').style.width = '100%';
199
+ document.getElementById('progressPct').textContent = '100%';
200
+ document.getElementById('progressStep').textContent = 'Selesai! Menyiapkan dashboard...';
201
+
202
+ SM.saveData(rows, meta);
203
+
204
+ setTimeout(() => {
205
+ SM.showModal({
206
+ title: 'Demo Selesai',
207
+ message: `Berhasil memproses <strong>${rows.length}</strong> tweet demo dengan model IndoBERT. Lihat hasilnya di dashboard!`,
208
+ type: 'success',
209
+ onConfirm: () => { window.location.href = 'dashboard.html'; }
210
+ });
211
+ }, 400);
212
+ } catch (err) {
213
+ SM.showToast('Gagal memproses demo: ' + err.message, 'error');
214
+ document.getElementById('progressWrap').style.display = 'none';
215
+ }
216
+ });
217
+ }
model/config.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "return_dict": true,
3
+ "output_hidden_states": false,
4
+ "dtype": "float32",
5
+ "chunk_size_feed_forward": 0,
6
+ "is_encoder_decoder": false,
7
+ "architectures": [
8
+ "BertForSequenceClassification"
9
+ ],
10
+ "id2label": {
11
+ "0": "Positif",
12
+ "1": "Netral",
13
+ "2": "Negatif"
14
+ },
15
+ "label2id": {
16
+ "Positif": 0,
17
+ "Netral": 1,
18
+ "Negatif": 2
19
+ },
20
+ "problem_type": "single_label_classification",
21
+ "_name_or_path": "mdhugol/indonesia-bert-sentiment-classification",
22
+ "transformers_version": "5.0.0",
23
+ "_num_labels": 5,
24
+ "directionality": "bidi",
25
+ "gradient_checkpointing": false,
26
+ "model_type": "bert",
27
+ "output_past": true,
28
+ "pooler_fc_size": 768,
29
+ "pooler_num_attention_heads": 12,
30
+ "pooler_num_fc_layers": 3,
31
+ "pooler_size_per_head": 128,
32
+ "pooler_type": "first_token_transform",
33
+ "position_embedding_type": "absolute",
34
+ "pad_token_id": 0,
35
+ "is_decoder": false,
36
+ "add_cross_attention": false,
37
+ "bos_token_id": null,
38
+ "eos_token_id": null,
39
+ "tie_word_embeddings": true,
40
+ "vocab_size": 50000,
41
+ "hidden_size": 768,
42
+ "num_hidden_layers": 12,
43
+ "num_attention_heads": 12,
44
+ "hidden_act": "gelu",
45
+ "intermediate_size": 3072,
46
+ "hidden_dropout_prob": 0.1,
47
+ "attention_probs_dropout_prob": 0.1,
48
+ "max_position_embeddings": 512,
49
+ "type_vocab_size": 2,
50
+ "initializer_range": 0.02,
51
+ "layer_norm_eps": 1e-12,
52
+ "use_cache": true,
53
+ "classifier_dropout": null,
54
+ "output_attentions": false
55
+ }
model/onnx/model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:764fa04b9803d8fcf34f4abd2141c7bc2ac505829ba3e1707ddd45236e6ff7e5
3
+ size 499366466
model/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
model/tokenizer_config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": false,
5
+ "is_local": false,
6
+ "mask_token": "[MASK]",
7
+ "model_max_length": 1000000000000000019884624838656,
8
+ "pad_token": "[PAD]",
9
+ "sep_token": "[SEP]",
10
+ "strip_accents": null,
11
+ "tokenize_chinese_chars": true,
12
+ "tokenizer_class": "BertTokenizer",
13
+ "unk_token": "[UNK]"
14
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ transformers
2
+ torch
3
+ flask
4
+ flask-cors
support.html ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
+ <title>Dukungan — SentiMeter</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
11
+ rel="stylesheet" />
12
+ <link rel="stylesheet" href="css/style.css" />
13
+ <link rel="stylesheet" href="css/support.css" />
14
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
15
+ </head>
16
+
17
+ <body>
18
+ <div class="layout">
19
+ <div id="sidebar"></div>
20
+ <div class="main">
21
+ <div class="topbar">
22
+ <div class="topbar-title">Dukungan</div>
23
+ </div>
24
+ <div class="page-body">
25
+
26
+ <div class="sec-head" style="margin-bottom: 24px;">
27
+ <div>
28
+ <div class="sec-title">Dukungan & Kontak</div>
29
+ <div class="sec-sub">Berikan dukungan atau hubungi pengembang untuk bantuan</div>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="support-grid">
34
+ <!-- Donation Card -->
35
+ <div class="support-card reveal-up" style="--d:100ms">
36
+ <div class="sup-icon-box" style="background:rgba(251,191,36,0.15); color:var(--neu)">
37
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
38
+ stroke-linecap="round" stroke-linejoin="round">
39
+ <path d="M12 2v20" />
40
+ <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
41
+ </svg>
42
+ </div>
43
+ <h3 class="sup-title">Dukungan Saweria</h3>
44
+ <p class="sup-desc">Dukung pengembangan berkelanjutan SentiMeter agar tetap gratis dan andal.</p>
45
+ <a href="https://saweria.co/rhmnsae" target="_blank" class="btn btn-primary sup-btn">Kirim Dukungan
46
+ (Saweria)</a>
47
+ </div>
48
+
49
+ <!-- Socials Card -->
50
+ <div class="support-card reveal-up" style="--d:200ms">
51
+ <div class="sup-icon-box" style="background:rgba(108,143,255,0.15); color:var(--acc)">
52
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
53
+ stroke-linecap="round" stroke-linejoin="round">
54
+ <path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
55
+ <rect width="4" height="12" x="2" y="9" />
56
+ <circle cx="4" cy="4" r="2" />
57
+ </svg>
58
+ </div>
59
+ <h3 class="sup-title">Media Sosial</h3>
60
+ <p class="sup-desc">Ikuti perkembangan terbaru dan terhubung dengan pengembang.</p>
61
+ <div class="sup-links">
62
+ <a href="https://github.com/rhmnsae" target="_blank" class="sup-link-item sup-link-github">
63
+ <span class="sup-link-icon">
64
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
65
+ stroke-linecap="round" stroke-linejoin="round">
66
+ <path
67
+ d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
68
+ </svg>
69
+ </span>
70
+ GitHub
71
+ </a>
72
+ <a href="https://www.instagram.com/saeplll" target="_blank" class="sup-link-item sup-link-insta">
73
+ <span class="sup-link-icon">
74
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
75
+ stroke-linecap="round" stroke-linejoin="round">
76
+ <rect width="20" height="20" x="2" y="2" rx="5" ry="5" />
77
+ <path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
78
+ <line x1="17.5" x2="17.51" y1="6.5" y2="6.5" />
79
+ </svg>
80
+ </span>
81
+ Instagram
82
+ </a>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Contact Card -->
87
+ <div class="support-card reveal-up" style="--d:300ms">
88
+ <div class="sup-icon-box" style="background:rgba(52,211,153,0.15); color:var(--pos)">
89
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
90
+ stroke-linecap="round" stroke-linejoin="round">
91
+ <path
92
+ d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
93
+ </svg>
94
+ </div>
95
+ <h3 class="sup-title">Pusat Kendala</h3>
96
+ <p class="sup-desc">Ada pertanyaan atau kendala teknis? Hubungi kami langsung via WhatsApp.</p>
97
+ <a href="https://wa.me/6283842707945" target="_blank" class="btn btn-primary sup-btn btn-wa"
98
+ style="background:#25D366; border-color:#25D366">
99
+ Chat via WhatsApp
100
+ </a>
101
+ <span class="wa-num">0838 4270 7945</span>
102
+ </div>
103
+ </div>
104
+
105
+ </div>
106
+ </div>
107
+ </div>
108
+ <script src="js/shared.js"></script>
109
+ <script src="js/support.js"></script>
110
+ </body>
111
+
112
+ </html>
tweets.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8" /><meta name="viewport" content="width=device-width,initial-scale=1.0" />
5
+ <title>Tweet List — SentiMeter</title>
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" /><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
8
+ <link rel="stylesheet" href="css/style.css" />
9
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
10
+ </head>
11
+ <body>
12
+ <div class="layout">
13
+ <div id="sidebar"></div>
14
+ <div class="main">
15
+ <div class="topbar">
16
+ <div class="topbar-title">Tweet List</div>
17
+ <div id="topbarMeta" class="topbar-sub"></div>
18
+ </div>
19
+ <div class="page-body">
20
+
21
+ <div class="sec-head">
22
+ <div>
23
+ <div class="sec-title">Daftar Tweet</div>
24
+ <div class="sec-sub">Tampilan postingan asli dengan analisis sentimen</div>
25
+ </div>
26
+ <div class="actions" id="tweetStats"></div>
27
+ </div>
28
+
29
+ <!-- Filter Bar -->
30
+ <div class="tl-filter-bar">
31
+ <div class="tl-filter-tabs">
32
+ <button class="tl-tab active" data-filter="all" id="btnAll">
33
+ <span class="tl-tab-label">Semua</span>
34
+ </button>
35
+ <button class="tl-tab tl-tab-pos" data-filter="Positif" id="btnPos">
36
+ <div class="tl-tab-label-group">
37
+ <span class="tl-tab-dot" style="background:var(--pos)"></span>
38
+ <span class="tl-tab-label">Positif</span>
39
+ </div>
40
+ <span class="tl-tab-count" id="cntPos">0</span>
41
+ </button>
42
+ <button class="tl-tab tl-tab-neg" data-filter="Negatif" id="btnNeg">
43
+ <div class="tl-tab-label-group">
44
+ <span class="tl-tab-dot" style="background:var(--neg)"></span>
45
+ <span class="tl-tab-label">Negatif</span>
46
+ </div>
47
+ <span class="tl-tab-count" id="cntNeg">0</span>
48
+ </button>
49
+ <button class="tl-tab tl-tab-neu" data-filter="Netral" id="btnNeu">
50
+ <div class="tl-tab-label-group">
51
+ <span class="tl-tab-dot" style="background:var(--neu)"></span>
52
+ <span class="tl-tab-label">Netral</span>
53
+ </div>
54
+ <span class="tl-tab-count" id="cntNeu">0</span>
55
+ </button>
56
+ </div>
57
+
58
+ <div class="tl-filter-actions">
59
+ <div class="tl-search-wrap">
60
+ <svg class="tl-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
61
+ <input class="tl-search" type="text" id="tweetSearch" placeholder="Cari tweet atau username…" />
62
+ <button class="tl-search-clear" id="searchClear" title="Hapus pencarian">✕</button>
63
+ </div>
64
+ <div class="tl-sort-wrap">
65
+ <select class="tl-sort-sel" id="sortSel">
66
+ <option value="default">Urutan Asli</option>
67
+ <option value="conf-desc">Confidence Tinggi</option>
68
+ <option value="conf-asc">Confidence Rendah</option>
69
+ <option value="engage-desc">Engagement Tinggi</option>
70
+ <option value="date-desc">Terbaru</option>
71
+ <option value="date-asc">Terlama</option>
72
+ </select>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Tweet Feed -->
78
+ <div id="tweetFeed" class="tl-feed"></div>
79
+
80
+ <!-- Info & Pagination -->
81
+ <div class="tl-pagination-stack">
82
+ <div class="tl-pagination" id="tweetPagination"></div>
83
+ <div class="tl-pagination-info" id="tweetInfo"></div>
84
+ <div class="tl-page-size-row">
85
+ <span class="tl-page-size-label">Baris per halaman:</span>
86
+ <select id="pageSizeSel" class="tl-page-size-sel">
87
+ <option value="10">10</option>
88
+ <option value="20" selected>20</option>
89
+ <option value="50">50</option>
90
+ <option value="100">100</option>
91
+ </select>
92
+ </div>
93
+ </div>
94
+
95
+ </div>
96
+ </div>
97
+ </div>
98
+ <script src="js/shared.js"></script>
99
+ <script src="js/tweets.js"></script>
100
+ </body>
101
+ </html>
upload.html ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
+ <title>Upload Data — SentiMeter</title>
8
+ <meta name="description" content="Upload file CSV untuk analisis sentimen IndoBERT Bahasa Indonesia" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
12
+ rel="stylesheet" />
13
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
14
+ <link rel="stylesheet" href="css/style.css" />
15
+ <link rel="icon" type="image/svg+xml" href="img/logo.svg" />
16
+ </head>
17
+
18
+ <body>
19
+ <div class="layout">
20
+ <div id="sidebar"></div>
21
+ <div class="main">
22
+ <div class="topbar">
23
+ <div class="topbar-title">Upload Data</div>
24
+ <div class="topbar-sub">Langkah 1 dari 6</div>
25
+ </div>
26
+ <div class="page-body upload-page-body">
27
+ <div class="upload-hero">
28
+ <div class="upload-headline">
29
+ <h1>Mulai Analisis Sentimen</h1>
30
+ <p>Silakan unggah dataset Twitter/X dalam format CSV. Sistem IndoBERT kami secara otomatis akan mendeteksi
31
+ kolom <code>full_text</code>, menerapkan 9 tahap text cleaning, dan memproses analisis sentimen secara
32
+ komprehensif tanpa memerlukan pengaturan manual.</p>
33
+ </div>
34
+
35
+ <div class="upload-zone" id="uploadZone">
36
+ <input type="file" id="csvInput" accept=".csv" multiple />
37
+ <div class="upload-inner" id="uploadInner">
38
+ <div class="upload-circle">
39
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
40
+ stroke-linecap="round" stroke-linejoin="round">
41
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
42
+ <polyline points="17 8 12 3 7 8" />
43
+ <line x1="12" y1="3" x2="12" y2="15" />
44
+ </svg>
45
+ </div>
46
+ <h2 class="upload-title">Seret & Lepas file CSV</h2>
47
+ <p class="upload-sub">atau klik area ini untuk memilih file</p>
48
+ <button class="btn btn-primary btn-upload-trigger"
49
+ onclick="document.getElementById('csvInput').click()">Pilih File CSV</button>
50
+ <p class="upload-hint">Format: <code>.csv</code> — kolom <code>full_text</code> wajib ada</p>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="demo-trigger-wrap" style="margin-top: 24px; text-align: center;">
55
+ <p style="font-size: 13px; color: var(--tx3); margin-bottom: 12px;">Tidak punya file CSV? Coba dengan data contoh kami.</p>
56
+ <button class="btn btn-outline" id="btnDemoData">
57
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
58
+ Coba Data Demo (300 Tweet)
59
+ </button>
60
+ </div>
61
+
62
+ <!-- File info preview -->
63
+ <div id="filePreview" style="display:none" class="file-preview">
64
+ <div class="file-info">
65
+ <div class="file-name" id="fileName">—</div>
66
+ <div class="file-meta" id="fileMeta">—</div>
67
+ </div>
68
+ <div class="file-actions">
69
+ <button class="btn btn-primary" id="btnAnalyze">Mulai Analisis</button>
70
+ <button class="btn btn-ghost btn-sm" id="btnCancel">Batal</button>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Progress -->
75
+ <div id="progressWrap" style="display:none" class="progress-wrap">
76
+ <div class="progress-header">
77
+ <span id="progressText">Memproses...</span>
78
+ <span id="progressPct">0%</span>
79
+ </div>
80
+ <div class="progress-track">
81
+ <div class="progress-bar" id="progressBar"></div>
82
+ </div>
83
+ <div class="progress-step" id="progressStep"></div>
84
+ </div>
85
+ </div>
86
+
87
+
88
+ </div>
89
+ </div>
90
+ </div>
91
+ <script src="js/shared.js"></script>
92
+ <script src="js/demo.js"></script>
93
+ <script src="js/upload.js"></script>
94
+ </body>
95
+
96
+ </html>