Alstears commited on
Commit
6f6024d
·
verified ·
1 Parent(s): 2a6f119

Upload 9 files

Browse files
Files changed (9) hide show
  1. Dockerfile +19 -0
  2. app.py +93 -0
  3. app_database.db +0 -0
  4. backend.py +662 -0
  5. database.py +283 -0
  6. index.html +265 -0
  7. requirements.txt +6 -0
  8. script.js +785 -0
  9. style.css +215 -0
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ build-essential \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ # Set permission for SQLite database
16
+ RUN chmod -R 777 /app
17
+
18
+ # Hugging Face Spaces default port is 7860
19
+ CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, File, UploadFile, Form
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ import torch, torch.nn as nn, timm, io, os, warnings, shutil
4
+ import torchvision.transforms as transforms
5
+ from PIL import Image
6
+
7
+ warnings.filterwarnings("ignore")
8
+
9
+ app = FastAPI(title="AI Forensic Detector API")
10
+
11
+ app.add_middleware(
12
+ CORSMiddleware,
13
+ allow_origins=["*"],
14
+ allow_credentials=True,
15
+ allow_methods=["*"],
16
+ allow_headers=["*"],
17
+ )
18
+
19
+ DEVICE = "cpu"
20
+ CKPT_PATH = "ckpt_best_v4.pth"
21
+ FEEDBACK_DIR = "feedback"
22
+
23
+ os.makedirs(f"{FEEDBACK_DIR}/real", exist_ok=True)
24
+ os.makedirs(f"{FEEDBACK_DIR}/fake", exist_ok=True)
25
+
26
+ print("⏳ Loading EfficientNet V4...")
27
+ try:
28
+ effnet_v4 = timm.create_model("efficientnet_b0", pretrained=False, num_classes=2)
29
+ ckpt = torch.load(CKPT_PATH, map_location=DEVICE, weights_only=False)
30
+ ckpt_state = ckpt["state_dict"] if "state_dict" in ckpt else ckpt
31
+ effnet_v4.load_state_dict(ckpt_state)
32
+ effnet_v4.to(DEVICE).eval()
33
+ print("✅ V4 Loaded!")
34
+ except Exception as e:
35
+ print(f"❌ Error loading model: {e}")
36
+ effnet_v4 = None
37
+
38
+ transform = transforms.Compose([
39
+ transforms.Resize((224, 224)),
40
+ transforms.ToTensor(),
41
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
42
+ ])
43
+
44
+ def predict_image(img: Image.Image):
45
+ if effnet_v4 is None:
46
+ return "REAL", 0.5
47
+
48
+ x = transform(img.convert("RGB")).unsqueeze(0).to(DEVICE)
49
+ with torch.no_grad():
50
+ prob = torch.softmax(effnet_v4(x), dim=1)[0].cpu().numpy()
51
+
52
+ p_ai = float(prob[1])
53
+
54
+ if p_ai > 0.80:
55
+ return "AI", round(p_ai, 4)
56
+ else:
57
+ return "REAL", round(1.0 - p_ai, 4)
58
+
59
+ @app.get("/")
60
+ def root():
61
+ return {"message": "AI Forensic Detector API is running", "model": "efficientnet_b0_v4"}
62
+
63
+ @app.post("/predict")
64
+ async def predict(file: UploadFile = File(...)):
65
+ ext = file.filename.lower().split('.')[-1]
66
+ if ext not in ('png', 'jpg', 'jpeg', 'webp'):
67
+ return {"error": "Format tidak didukung"}
68
+
69
+ contents = await file.read()
70
+ img = Image.open(io.BytesIO(contents))
71
+ prediction, confidence = predict_image(img)
72
+
73
+ return {
74
+ "filename": file.filename,
75
+ "prediction": prediction,
76
+ "confidence": confidence,
77
+ "file_size": len(contents)
78
+ }
79
+
80
+ @app.post("/save-feedback")
81
+ async def save_feedback(file: UploadFile = File(...), correct_label: str = Form(...)):
82
+ folder = "real" if correct_label.upper() == "REAL" else "fake"
83
+ path = f"{FEEDBACK_DIR}/{folder}/{file.filename}"
84
+
85
+ contents = await file.read()
86
+ with open(path, "wb") as f:
87
+ f.write(contents)
88
+
89
+ return {"status": "saved", "path": path}
90
+
91
+ if __name__ == "__main__":
92
+ import uvicorn
93
+ uvicorn.run(app, host="0.0.0.0", port=5000)
app_database.db ADDED
Binary file (41 kB). View file
 
backend.py ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import HTMLResponse, FileResponse
4
+ import shutil, os, time, uuid, zipfile
5
+ import database
6
+ import httpx
7
+
8
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
9
+ FEEDBACK_DIR = os.path.join(BASE_DIR, "feedback")
10
+ PENDING_DIR = os.path.join(FEEDBACK_DIR, "pending")
11
+ os.makedirs(f"{FEEDBACK_DIR}/real", exist_ok=True)
12
+ os.makedirs(f"{FEEDBACK_DIR}/fake", exist_ok=True)
13
+ os.makedirs(PENDING_DIR, exist_ok=True)
14
+
15
+ app = FastAPI()
16
+
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=["*"],
20
+ allow_credentials=True,
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ @app.get("/")
26
+ def root():
27
+ with open(os.path.join(BASE_DIR, "index.html"), encoding="utf-8") as f:
28
+ return HTMLResponse(f.read())
29
+
30
+ @app.get("/style.css")
31
+ def serve_css():
32
+ with open(os.path.join(BASE_DIR, "style.css"), encoding="utf-8") as f:
33
+ return HTMLResponse(f.read(), media_type="text/css")
34
+
35
+ @app.get("/template/style.css")
36
+ def serve_template_css():
37
+ with open(os.path.join(BASE_DIR, "template", "style.css"), encoding="utf-8") as f:
38
+ return HTMLResponse(f.read(), media_type="text/css")
39
+
40
+ @app.get("/script.js")
41
+ def serve_js():
42
+ with open(os.path.join(BASE_DIR, "script.js"), encoding="utf-8") as f:
43
+ return HTMLResponse(f.read(), media_type="application/javascript")
44
+
45
+ @app.on_event("startup")
46
+ def startup():
47
+ database.init_db()
48
+ database.migrate_existing_learning_data()
49
+ database.sync_scan_history_to_test_results()
50
+
51
+ @app.post("/api/register")
52
+ def api_register(username: str = Form(...), password: str = Form(...), name: str = Form(...)):
53
+ if database.register_user(username, password, name):
54
+ return {"status": "success", "message": "Registrasi berhasil!"}
55
+ raise HTTPException(status_code=400, detail="Username sudah digunakan")
56
+
57
+ @app.post("/api/login")
58
+ def api_login(username: str = Form(...), password: str = Form(...)):
59
+ user = database.login_user(username, password)
60
+ if user:
61
+ return {
62
+ "status": "success",
63
+ "name": user["name"],
64
+ "username": user["username"],
65
+ "trust_score": user["trust_score"] if "trust_score" in user and user["trust_score"] is not None else 50
66
+ }
67
+ raise HTTPException(status_code=401, detail="Username atau password salah")
68
+
69
+ import math
70
+ from PIL import Image
71
+
72
+ def get_color_feature_vector(img_path):
73
+ try:
74
+ with Image.open(img_path) as img:
75
+ img = img.resize((64, 64))
76
+ hist = img.histogram()
77
+ bins = []
78
+ for i in range(0, len(hist), 32):
79
+ bins.append(sum(hist[i:i+32]))
80
+ return bins
81
+ except Exception:
82
+ return [1.0] * 24
83
+
84
+ def cosine_similarity(v1, v2):
85
+ dot_product = sum(a * b for a, b in zip(v1, v2))
86
+ norm_v1 = math.sqrt(sum(a * a for a in v1))
87
+ norm_v2 = math.sqrt(sum(b * b for b in v2))
88
+ if norm_v1 == 0 or norm_v2 == 0:
89
+ return 0.0
90
+ return dot_product / (norm_v1 * norm_v2)
91
+
92
+ # Reference vector representing typical digital photo color centroid
93
+ REF_VECTOR = [100.0, 150.0, 200.0, 180.0, 120.0, 90.0, 80.0, 110.0, 130.0, 140.0, 160.0, 170.0, 190.0, 210.0, 220.0, 230.0, 240.0, 250.0, 200.0, 150.0, 100.0, 80.0, 60.0, 40.0]
94
+
95
+ def analyze_image_conditions(img_path):
96
+ try:
97
+ with Image.open(img_path) as img:
98
+ img_rgb = img.convert('RGB')
99
+ img_small = img_rgb.resize((32, 32))
100
+ pixels = list(img_small.getdata())
101
+
102
+ grayscale_diffs = []
103
+ brightness_vals = []
104
+ for r, g, b in pixels:
105
+ brightness = 0.299*r + 0.587*g + 0.114*b
106
+ brightness_vals.append(brightness)
107
+ diff = abs(r - g) + abs(g - b) + abs(b - r)
108
+ grayscale_diffs.append(diff)
109
+
110
+ avg_brightness = sum(brightness_vals) / len(brightness_vals)
111
+ avg_diff = sum(grayscale_diffs) / len(grayscale_diffs)
112
+
113
+ is_dark = 1 if avg_brightness < 45 else 0
114
+ is_grayscale = 1 if avg_diff < 12 else 0
115
+
116
+ return bool(is_dark), bool(is_grayscale), round(avg_brightness, 1)
117
+ except Exception:
118
+ return False, False, 127.0
119
+
120
+ def save_compressed_image(source_path, target_path, max_size=(512, 512)):
121
+ try:
122
+ with Image.open(source_path) as img:
123
+ if img.mode in ("RGBA", "P"):
124
+ img = img.convert("RGB")
125
+ img.thumbnail(max_size, Image.Resampling.LANCZOS)
126
+ ext = target_path.split('.')[-1].lower()
127
+ fmt = "PNG" if ext == "png" else "JPEG"
128
+ if fmt == "JPEG":
129
+ img.save(target_path, "JPEG", quality=80, optimize=True)
130
+ else:
131
+ img.save(target_path, "PNG", optimize=True)
132
+ return True
133
+ except Exception:
134
+ import shutil
135
+ shutil.copy2(source_path, target_path)
136
+ return False
137
+
138
+ HF_API_URL = "https://alstears-ai-forensic-detector.hf.space/predict"
139
+
140
+ @app.post("/api/scan-image")
141
+ def api_scan_image(file: UploadFile = File(...), username: str = Form(...)):
142
+ if not file.filename.lower().endswith(('png', 'jpg', 'jpeg', 'webp')):
143
+ raise HTTPException(status_code=400, detail="Format gambar tidak didukung")
144
+
145
+ uid = uuid.uuid4().hex[:8]
146
+ safe_name = file.filename.replace("\\", "/").split("/")[-1]
147
+ temp_path = f"temp_{uid}_{safe_name}"
148
+ with open(temp_path, "wb") as buffer:
149
+ shutil.copyfileobj(file.file, buffer)
150
+
151
+ try:
152
+ with open(temp_path, "rb") as f:
153
+ resp = httpx.post(HF_API_URL, files={"file": (file.filename, f, "image/jpeg")}, timeout=30)
154
+
155
+ if resp.status_code != 200:
156
+ raise HTTPException(status_code=502, detail="Gagal menghubungi AI detector")
157
+
158
+ result = resp.json()
159
+ prediction = result.get("prediction", "REAL")
160
+ confidence = result.get("confidence", 0.0)
161
+
162
+ is_ai = prediction.upper() in ("AI", "FAKE")
163
+ source = "Pollinations AI (Stable Diffusion)" if is_ai else "Kamera/Foto Digital Asli"
164
+ accuracy = round(confidence * 100, 1)
165
+
166
+ feedback_path = f"{PENDING_DIR}/{uid}_{safe_name}"
167
+ save_compressed_image(temp_path, feedback_path)
168
+
169
+ file_size = os.path.getsize(temp_path)
170
+ database.add_scan_history(username, file.filename, "Image", f"{file_size/(1024*1024):.2f} MB", source, is_ai, accuracy)
171
+
172
+ # Calculate color similarity & outlier detection (Solusi 2)
173
+ vector = get_color_feature_vector(temp_path)
174
+ similarity = cosine_similarity(vector, REF_VECTOR)
175
+ similarity = round(similarity, 3)
176
+ is_outlier = 1 if similarity < 0.88 else 0
177
+
178
+ # Detect low-light and monochrome conditions
179
+ is_dark, is_grayscale, avg_brightness = analyze_image_conditions(temp_path)
180
+
181
+ # Check for Trap image (Solusi 3)
182
+ is_trap = 1 if "trap" in file.filename.lower() else 0
183
+
184
+ # Get user's current trust score
185
+ conn = database.get_connection()
186
+ user_row = conn.execute("SELECT trust_score FROM users WHERE username=?", (username,)).fetchone()
187
+ trust_score = user_row["trust_score"] if user_row else 50
188
+ conn.close()
189
+
190
+ # --- SINKRONISASI AKURASI GAMBAR TUNGGAL (Poin 2) ---
191
+ # Coba tebak ground truth (REAL/AI) dari nama file (misal: real11.jpg, fake4.jpg)
192
+ prediction_label = "AI" if is_ai else "REAL"
193
+ inferred_label = None
194
+ fn_lower = file.filename.lower()
195
+ if "real" in fn_lower:
196
+ inferred_label = "REAL"
197
+ elif "fake" in fn_lower or "ai" in fn_lower:
198
+ inferred_label = "AI"
199
+
200
+ if inferred_label:
201
+ is_mismatch = 1 if prediction_label != inferred_label else 0
202
+ database.save_test_result(
203
+ None, username, file.filename, inferred_label,
204
+ prediction_label, accuracy, is_mismatch
205
+ )
206
+ # Simpan data pembelajaran mismatch jika tebakan salah
207
+ if is_mismatch:
208
+ database.save_learning_data(
209
+ username, file.filename, prediction_label, inferred_label, accuracy,
210
+ source="single_mismatch"
211
+ )
212
+
213
+ return {
214
+ "status": "success",
215
+ "filename": file.filename,
216
+ "feedback_id": uid,
217
+ "type": "image",
218
+ "file_size": f"{file_size/(1024*1024):.2f} MB",
219
+ "source": source,
220
+ "is_ai": is_ai,
221
+ "accuracy": accuracy,
222
+ "date": time.strftime("%Y-%m-%d %H:%M:%S"),
223
+ "similarity": similarity,
224
+ "is_outlier": bool(is_outlier),
225
+ "is_trap": bool(is_trap),
226
+ "trust_score": trust_score,
227
+ "is_dark": is_dark,
228
+ "is_grayscale": is_grayscale,
229
+ "avg_brightness": avg_brightness
230
+ }
231
+ finally:
232
+ if os.path.exists(temp_path): os.remove(temp_path)
233
+
234
+ def scan_single_image(file_bytes, filename):
235
+ resp = httpx.post(HF_API_URL, files={"file": (filename, file_bytes, "image/jpeg")}, timeout=30)
236
+ if resp.status_code != 200:
237
+ return None
238
+ return resp.json()
239
+
240
+ @app.post("/api/batch-scan")
241
+ async def api_batch_scan(files: list[UploadFile] = File(...), username: str = Form(...), labels: str = Form("[]")):
242
+ import json as json_mod
243
+ try:
244
+ parsed_labels = json_mod.loads(labels)
245
+ except:
246
+ parsed_labels = []
247
+
248
+ results = []
249
+ saved_bytes = {}
250
+
251
+ for idx, file in enumerate(files):
252
+ ext = file.filename.lower().split('.')[-1]
253
+ if ext not in ('png', 'jpg', 'jpeg', 'webp'):
254
+ continue
255
+
256
+ folder_label = None
257
+ # Safe lookup in dictionary map or fallback to list
258
+ if isinstance(parsed_labels, dict):
259
+ # Try exact match, then fallback to case-insensitive match
260
+ folder_label = parsed_labels.get(file.filename)
261
+ if not folder_label:
262
+ # Extract just the base filename in case of relative path difference
263
+ base_filename = file.filename.replace("\\", "/").split("/")[-1]
264
+ for k, v in parsed_labels.items():
265
+ k_base = k.replace("\\", "/").split("/")[-1]
266
+ if k_base.lower() == base_filename.lower():
267
+ folder_label = v
268
+ break
269
+ elif isinstance(parsed_labels, list) and idx < len(parsed_labels):
270
+ folder_label = parsed_labels[idx]
271
+
272
+ # Standardize folder label to uppercase
273
+ if folder_label:
274
+ folder_label_upper = str(folder_label).upper()
275
+ folder_label = "AI" if folder_label_upper in ("FAKE", "AI") else "REAL"
276
+
277
+ bytes_data = await file.read()
278
+
279
+ try:
280
+ resp = httpx.post(HF_API_URL, files={"file": (file.filename, bytes_data, "image/jpeg")}, timeout=30)
281
+
282
+ if resp.status_code != 200:
283
+ results.append({"filename": file.filename, "folder_label": folder_label, "error": "Gagal scan"})
284
+ continue
285
+
286
+ result = resp.json()
287
+ prediction = result.get("prediction", "REAL")
288
+ confidence = result.get("confidence", 0.0)
289
+ prediction_label = "AI" if prediction.upper() in ("AI", "FAKE") else "REAL"
290
+ confidence_pct = round(confidence * 100, 1)
291
+
292
+ is_mismatch = 0
293
+ if folder_label:
294
+ expected = "AI" if folder_label.upper() in ("FAKE", "AI") else "REAL"
295
+ if prediction_label != expected:
296
+ is_mismatch = 1
297
+
298
+ results.append({
299
+ "filename": file.filename,
300
+ "folder_label": folder_label,
301
+ "prediction": prediction_label,
302
+ "confidence": confidence_pct,
303
+ "is_mismatch": is_mismatch
304
+ })
305
+
306
+ if is_mismatch and folder_label:
307
+ uid = uuid.uuid4().hex[:8]
308
+ safe_name = file.filename.replace("\\", "/").split("/")[-1]
309
+ pending_path = f"{PENDING_DIR}/{uid}_{safe_name}"
310
+ with open(pending_path, "wb") as pf:
311
+ pf.write(bytes_data)
312
+ saved_bytes[file.filename] = uid
313
+ results[-1]["feedback_id"] = uid
314
+ else:
315
+ results[-1]["feedback_id"] = ""
316
+ except Exception as e:
317
+ results.append({"filename": file.filename, "folder_label": folder_label, "error": str(e)})
318
+
319
+ total = len(results)
320
+ mismatches = [r for r in results if r.get("is_mismatch")]
321
+ mismatch_count = len(mismatches)
322
+ correct_count = total - mismatch_count
323
+ accuracy = round((correct_count / total * 100), 1) if total > 0 else 0
324
+
325
+ batch_id = database.create_test_batch(username, total, correct_count, mismatch_count, accuracy)
326
+ for r in results:
327
+ database.save_test_result(
328
+ batch_id, username, r["filename"], r.get("folder_label"),
329
+ r.get("prediction", "ERROR"), r.get("confidence", 0.0), r.get("is_mismatch", 0)
330
+ )
331
+ if r.get("is_mismatch") and r.get("folder_label"):
332
+ expected = "AI" if r["folder_label"].lower() in ("fake", "ai") else "REAL"
333
+ database.save_learning_data(
334
+ username, r["filename"], r.get("prediction", "ERROR"), expected, r.get("confidence", 0.0),
335
+ source="batch_mismatch"
336
+ )
337
+ fid = r.get("feedback_id", "")
338
+ if fid:
339
+ safe_name = r['filename'].replace("\\", "/").split("/")[-1]
340
+ pending_file = f"{PENDING_DIR}/{fid}_{safe_name}"
341
+ target_dir = f"{FEEDBACK_DIR}/real" if expected == "REAL" else f"{FEEDBACK_DIR}/fake"
342
+ target_path = f"{target_dir}/{safe_name}"
343
+ if os.path.exists(pending_file):
344
+ os.makedirs(target_dir, exist_ok=True)
345
+ shutil.move(pending_file, target_path)
346
+
347
+ return {
348
+ "batch_id": batch_id,
349
+ "total": total,
350
+ "correct": correct_count,
351
+ "wrong": mismatch_count,
352
+ "accuracy": accuracy,
353
+ "results": results,
354
+ "needs_confirmation": mismatch_count if 1 <= mismatch_count <= 5 else 0
355
+ }
356
+
357
+ @app.post("/api/batch-confirm")
358
+ def api_batch_confirm(data: dict):
359
+ batch_id = data.get("batch_id")
360
+ corrections = data.get("corrections", [])
361
+ results = database.get_test_results_by_batch(batch_id)
362
+
363
+ for corr in corrections:
364
+ idx = corr.get("index")
365
+ user_answer = corr.get("user_answer")
366
+ if idx < len(results):
367
+ r = results[idx]
368
+ corrected_label = user_answer.upper()
369
+ original_prediction = r["prediction"]
370
+ confidence = r["confidence"]
371
+ database.update_test_result_correction(r["id"], corrected_label, corrected_label)
372
+ if original_prediction != corrected_label:
373
+ database.save_learning_data(
374
+ r["username"], r["filename"],
375
+ original_prediction, corrected_label, confidence
376
+ )
377
+
378
+ results = database.get_test_results_by_batch(batch_id)
379
+ total_with_label = 0
380
+ correct = 0
381
+ for r in results:
382
+ if not r["folder_label"]:
383
+ continue
384
+ total_with_label += 1
385
+ final_label = r.get("corrected_label") or r["prediction"]
386
+ expected = "AI" if r["folder_label"].lower() in ("fake", "ai") else "REAL"
387
+ if final_label == expected:
388
+ correct += 1
389
+ wrong = total_with_label - correct
390
+ accuracy = round((correct / total_with_label * 100), 1) if total_with_label > 0 else 0
391
+
392
+ return {"status": "success", "total": total_with_label, "correct": correct, "wrong": wrong, "accuracy": accuracy}
393
+
394
+ @app.post("/api/correction-single")
395
+ def api_correction_single(data: dict):
396
+ username = data.get("username")
397
+ filename = data.get("filename")
398
+ original_prediction = data.get("original_prediction")
399
+ correct_label = data.get("correct_label")
400
+ confidence = data.get("confidence", 0)
401
+ feedback_id = data.get("feedback_id")
402
+
403
+ # Standardize correct_label to uppercase
404
+ correct_label = "AI" if str(correct_label).upper() in ("FAKE", "AI") else "REAL"
405
+ original_prediction = "AI" if str(original_prediction).upper() in ("FAKE", "AI") else "REAL"
406
+
407
+ # Determine if it is a trap image and adjust trust score
408
+ is_trap = "trap" in filename.lower()
409
+ trap_correct = False
410
+ trust_change = 0
411
+ new_trust = 50
412
+
413
+ if is_trap:
414
+ if "trap_real" in filename.lower():
415
+ true_label = "REAL"
416
+ elif "trap_ai" in filename.lower() or "trap_fake" in filename.lower():
417
+ true_label = "AI"
418
+ else:
419
+ true_label = "REAL" if original_prediction == "AI" else "AI"
420
+
421
+ if correct_label == true_label:
422
+ trap_correct = True
423
+ trust_change = 5
424
+ else:
425
+ trap_correct = False
426
+ trust_change = -15
427
+
428
+ conn = database.get_connection()
429
+ user_row = conn.execute("SELECT trust_score FROM users WHERE username=?", (username,)).fetchone()
430
+ if user_row:
431
+ current_trust = user_row["trust_score"] if user_row["trust_score"] is not None else 50
432
+ new_trust = max(0, min(100, current_trust + trust_change))
433
+ conn.execute("UPDATE users SET trust_score=? WHERE username=?", (new_trust, username))
434
+ conn.commit()
435
+ conn.close()
436
+
437
+ # Save to learning data for retraining
438
+ database.save_learning_data(username, filename, original_prediction, correct_label, confidence)
439
+
440
+ # Update or insert into test_results
441
+ conn = database.get_connection()
442
+ existing = conn.execute("SELECT id FROM test_results WHERE username=? AND filename=? AND batch_id IS NULL",
443
+ (username, filename)).fetchone()
444
+ is_mismatch = 1 if original_prediction != correct_label else 0
445
+ if existing:
446
+ conn.execute("UPDATE test_results SET folder_label=?, prediction=?, confidence=?, is_mismatch=?, corrected_label=? WHERE id=?",
447
+ (correct_label, original_prediction, confidence, is_mismatch, correct_label, existing["id"]))
448
+ conn.commit()
449
+ else:
450
+ database.save_test_result(None, username, filename, correct_label, original_prediction, confidence, is_mismatch)
451
+ conn.close()
452
+
453
+ if feedback_id:
454
+ safe_name = filename.replace("\\", "/").split("/")[-1]
455
+ pending_file = f"{PENDING_DIR}/{feedback_id}_{safe_name}"
456
+ target_dir = f"{FEEDBACK_DIR}/real" if correct_label == "REAL" else f"{FEEDBACK_DIR}/fake"
457
+ target_path = f"{target_dir}/{safe_name}"
458
+ if os.path.exists(pending_file):
459
+ os.makedirs(target_dir, exist_ok=True)
460
+ save_compressed_image(pending_file, target_path)
461
+ os.remove(pending_file)
462
+
463
+ return {
464
+ "status": "success",
465
+ "is_trap": is_trap,
466
+ "trap_correct": trap_correct,
467
+ "trust_change": trust_change,
468
+ "new_trust": new_trust
469
+ }
470
+
471
+ @app.get("/api/history/{username}")
472
+ def api_get_history(username: str):
473
+ return {"history": database.get_user_history(username)}
474
+
475
+ @app.get("/api/clear-history")
476
+ def api_clear_history():
477
+ database.clear_all_history()
478
+ return {"status": "success", "message": "Semua history berhasil dihapus"}
479
+
480
+ @app.get("/api/accuracy-report")
481
+ def api_accuracy_report(username: str = None, filter: str = "all"):
482
+ import datetime
483
+ import traceback
484
+
485
+ try:
486
+ now = datetime.datetime.now()
487
+
488
+ # Calculate time threshold
489
+ threshold_str = None
490
+ if filter == "today":
491
+ threshold_str = now.strftime("%Y-%m-%d")
492
+ elif filter == "week":
493
+ threshold_str = (now - datetime.timedelta(days=7)).isoformat()
494
+ elif filter == "month":
495
+ threshold_str = (now - datetime.timedelta(days=30)).isoformat()
496
+
497
+ conn = database.get_connection()
498
+
499
+ # Fetch test results with time filter
500
+ query_results = "SELECT * FROM test_results"
501
+ params_results = []
502
+ conditions = []
503
+ if username:
504
+ conditions.append("username = ?")
505
+ params_results.append(username)
506
+ if threshold_str:
507
+ conditions.append("scan_date >= ?")
508
+ params_results.append(threshold_str)
509
+
510
+ if conditions:
511
+ query_results += " WHERE " + " AND ".join(conditions)
512
+ rows = conn.execute(query_results, params_results).fetchall()
513
+
514
+ # Compute Confusion Matrix, Failures, and Confidence Distributions
515
+ tp = 0
516
+ fp = 0
517
+ fn = 0
518
+ tn = 0
519
+
520
+ real_conf_buckets = [0, 0, 0, 0, 0] # 50-60, 60-70, 70-80, 80-90, 90-100
521
+ ai_conf_buckets = [0, 0, 0, 0, 0]
522
+
523
+ failures = []
524
+
525
+ for r in rows:
526
+ if not r["folder_label"]:
527
+ continue
528
+ expected = "AI" if r["folder_label"].lower() in ("fake", "ai") else "REAL"
529
+ final_pred = r["corrected_label"] or r["prediction"] or "REAL"
530
+
531
+ # Safe None check for confidence
532
+ confidence = float(r["confidence"]) if r["confidence"] is not None else 0.0
533
+
534
+ if expected == "AI" and final_pred == "AI":
535
+ tp += 1
536
+ elif expected == "REAL" and final_pred == "AI":
537
+ fp += 1
538
+ elif expected == "AI" and final_pred == "REAL":
539
+ fn += 1
540
+ elif expected == "REAL" and final_pred == "REAL":
541
+ tn += 1
542
+
543
+ # Add mismatch (prediction failure) to failure log
544
+ if final_pred != expected:
545
+ failures.append({
546
+ "filename": r["filename"] or "Unknown File",
547
+ "expected": expected,
548
+ "prediction": r["prediction"] or "REAL",
549
+ "final_pred": final_pred,
550
+ "confidence": confidence,
551
+ "date": r["scan_date"][:19].replace("T", " ") if r["scan_date"] else "-"
552
+ })
553
+
554
+ bucket_idx = min(int((confidence - 50) / 10), 4)
555
+ if bucket_idx >= 0:
556
+ if final_pred == "AI":
557
+ ai_conf_buckets[bucket_idx] += 1
558
+ else:
559
+ real_conf_buckets[bucket_idx] += 1
560
+
561
+ # Calculate global metrics
562
+ total = tp + fp + fn + tn
563
+ correct = tp + tn
564
+ wrong = fp + fn
565
+ accuracy = round((correct / total * 100), 1) if total > 0 else 0.0
566
+
567
+ # Calculate Advanced ML metrics
568
+ precision = round((tp / (tp + fp) * 100), 1) if (tp + fp) > 0 else 0.0
569
+ recall = round((tp / (tp + fn) * 100), 1) if (tp + fn) > 0 else 0.0
570
+ f1_score = round((2 * (precision * recall) / (precision + recall)), 1) if (precision + recall) > 0 else 0.0
571
+
572
+ # Count scans with time filter
573
+ q_scan = "SELECT COUNT(*) as cnt FROM scan_history"
574
+ p_scan = []
575
+ if username or threshold_str:
576
+ conds = []
577
+ if username:
578
+ conds.append("username = ?")
579
+ p_scan.append(username)
580
+ if threshold_str:
581
+ conds.append("scan_date >= ?")
582
+ p_scan.append(threshold_str)
583
+ q_scan += " WHERE " + " AND ".join(conds)
584
+ scan_count = conn.execute(q_scan, p_scan).fetchone()["cnt"]
585
+
586
+ # Count batch images with time filter
587
+ q_batch = "SELECT COALESCE(SUM(total_images), 0) as cnt FROM test_batches"
588
+ p_batch = []
589
+ if username or threshold_str:
590
+ conds = []
591
+ if username:
592
+ conds.append("username = ?")
593
+ p_batch.append(username)
594
+ if threshold_str:
595
+ conds.append("test_date >= ?")
596
+ p_batch.append(threshold_str)
597
+ q_batch += " WHERE " + " AND ".join(conds)
598
+ batch_images = conn.execute(q_batch, p_batch).fetchone()["cnt"]
599
+
600
+ # Count other parameters
601
+ learning_count = database.get_learning_data_count()
602
+
603
+ q_batches = "SELECT * FROM test_batches"
604
+ p_batches = []
605
+ conds_b = []
606
+ if username:
607
+ conds_b.append("username = ?")
608
+ p_batches.append(username)
609
+ if threshold_str:
610
+ conds_b.append("test_date >= ?")
611
+ p_batches.append(threshold_str)
612
+ if conds_b:
613
+ q_batches += " WHERE " + " AND ".join(conds_b)
614
+ q_batches += " ORDER BY id DESC"
615
+ batches_rows = conn.execute(q_batches, p_batches).fetchall()
616
+ batches = [dict(row) for row in batches_rows]
617
+
618
+ conn.close()
619
+
620
+ return {
621
+ "stats": {
622
+ "total": total,
623
+ "correct": correct,
624
+ "wrong": wrong,
625
+ "accuracy": accuracy,
626
+ "precision": precision,
627
+ "recall": recall,
628
+ "f1_score": f1_score
629
+ },
630
+ "confusion_matrix": {
631
+ "tp": tp,
632
+ "fp": fp,
633
+ "fn": fn,
634
+ "tn": tn
635
+ },
636
+ "confidence_distribution": {
637
+ "buckets": ["50-60%", "60-70%", "70-80%", "80-90%", "90-100%"],
638
+ "real": real_conf_buckets,
639
+ "ai": ai_conf_buckets
640
+ },
641
+ "failures": failures[:15], # limit to latest 15 failures
642
+ "batches": batches,
643
+ "learning_data_count": learning_count,
644
+ "scan_count": scan_count,
645
+ "batch_images": batch_images
646
+ }
647
+ except Exception as e:
648
+ print("EXCEPTION DETECTED IN ACCURACY REPORT:")
649
+ traceback.print_exc()
650
+ raise HTTPException(status_code=500, detail=str(e))
651
+ @app.get("/api/download-feedback")
652
+ def api_download_feedback(background: BackgroundTasks):
653
+ zip_path = os.path.join(BASE_DIR, f"feedback_{time.strftime('%Y%m%d_%H%M%S')}.zip")
654
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
655
+ for root, dirs, files in os.walk(FEEDBACK_DIR):
656
+ for file in files:
657
+ file_path = os.path.join(root, file)
658
+ arcname = os.path.relpath(file_path, FEEDBACK_DIR)
659
+ zf.write(file_path, arcname)
660
+ background.add_task(os.remove, zip_path)
661
+ return FileResponse(zip_path, media_type="application/zip",
662
+ filename=os.path.basename(zip_path))
database.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ import os
4
+ import hashlib
5
+ from datetime import datetime
6
+
7
+ DATASET_CONFIG = {
8
+ "dataset_dir": "./dataset_ai_vs_real",
9
+ "real_dir": "./dataset_ai_vs_real/real",
10
+ "ai_dir": "./dataset_ai_vs_real/fake"
11
+ }
12
+
13
+ DB_NAME = "app_database.db"
14
+
15
+ def get_connection():
16
+ conn = sqlite3.connect(DB_NAME)
17
+ conn.row_factory = sqlite3.Row
18
+ return conn
19
+
20
+ def init_db():
21
+ conn = get_connection()
22
+ cursor = conn.cursor()
23
+ cursor.execute('''CREATE TABLE IF NOT EXISTS users (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ username TEXT UNIQUE NOT NULL,
26
+ password TEXT NOT NULL,
27
+ name TEXT NOT NULL,
28
+ join_date TEXT,
29
+ trust_score INTEGER DEFAULT 50)''')
30
+ try:
31
+ cursor.execute("ALTER TABLE users ADD COLUMN trust_score INTEGER DEFAULT 50")
32
+ except sqlite3.OperationalError:
33
+ pass
34
+ cursor.execute('''CREATE TABLE IF NOT EXISTS scan_history (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ username TEXT NOT NULL,
37
+ filename TEXT,
38
+ file_type TEXT,
39
+ file_size TEXT,
40
+ source TEXT,
41
+ is_ai INTEGER,
42
+ accuracy REAL,
43
+ scan_date TEXT)''')
44
+ cursor.execute('''CREATE TABLE IF NOT EXISTS test_batches (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ username TEXT NOT NULL,
47
+ total_images INTEGER,
48
+ correct_count INTEGER,
49
+ wrong_count INTEGER,
50
+ accuracy REAL,
51
+ test_date TEXT)''')
52
+ cursor.execute('''CREATE TABLE IF NOT EXISTS test_results (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ batch_id INTEGER,
55
+ username TEXT NOT NULL,
56
+ filename TEXT,
57
+ folder_label TEXT,
58
+ prediction TEXT,
59
+ confidence REAL,
60
+ is_mismatch INTEGER,
61
+ user_correction TEXT,
62
+ corrected_label TEXT,
63
+ scan_date TEXT)''')
64
+ cursor.execute('''CREATE TABLE IF NOT EXISTS learning_data (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ username TEXT NOT NULL,
67
+ filename TEXT,
68
+ original_prediction TEXT,
69
+ correct_label TEXT,
70
+ confidence REAL,
71
+ source TEXT,
72
+ scan_date TEXT)''')
73
+ conn.commit()
74
+ conn.close()
75
+ for path in DATASET_CONFIG.values():
76
+ if not os.path.exists(path):
77
+ os.makedirs(path, exist_ok=True)
78
+
79
+ def hash_password(password):
80
+ return hashlib.sha256(password.encode()).hexdigest()
81
+
82
+ def register_user(username, password, name):
83
+ conn = get_connection()
84
+ try:
85
+ conn.execute("INSERT INTO users (username, password, name, join_date) VALUES (?, ?, ?, ?)",
86
+ (username, hash_password(password), name, datetime.now().isoformat()))
87
+ conn.commit()
88
+ return True
89
+ except sqlite3.IntegrityError:
90
+ return False
91
+ finally:
92
+ conn.close()
93
+
94
+ def login_user(username, password):
95
+ conn = get_connection()
96
+ user = conn.execute("SELECT * FROM users WHERE username=? AND password=?",
97
+ (username, hash_password(password))).fetchone()
98
+ conn.close()
99
+ return dict(user) if user else None
100
+
101
+ def add_scan_history(username, filename, file_type, file_size, source, is_ai, accuracy):
102
+ conn = get_connection()
103
+ conn.execute('''INSERT INTO scan_history (username, filename, file_type, file_size, source, is_ai, accuracy, scan_date)
104
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
105
+ (username, filename, file_type, file_size, source, is_ai, accuracy, datetime.now().isoformat()))
106
+ conn.commit()
107
+ conn.close()
108
+
109
+ def get_user_history(username):
110
+ conn = get_connection()
111
+ scan_rows = conn.execute("SELECT * FROM scan_history WHERE username=? ORDER BY id DESC LIMIT 20", (username,)).fetchall()
112
+ batch_rows = conn.execute('''SELECT tb.id, tb.total_images, tb.correct_count, tb.wrong_count, tb.accuracy, tb.test_date
113
+ FROM test_batches tb WHERE tb.username=? ORDER BY tb.id DESC LIMIT 20''', (username,)).fetchall()
114
+ conn.close()
115
+
116
+ history = []
117
+ for row in scan_rows:
118
+ r = dict(row)
119
+ r["_type"] = "scan"
120
+ history.append(r)
121
+ for row in batch_rows:
122
+ r = dict(row)
123
+ r["_type"] = "batch"
124
+ r["filename"] = f"Batch #{r['id']} ({r['total_images']} gambar)"
125
+ r["file_type"] = "Batch"
126
+ r["file_size"] = "-"
127
+ r["source"] = f"{r['correct_count']} benar / {r['wrong_count']} salah"
128
+ r["is_ai"] = 0
129
+ r["accuracy"] = r["accuracy"]
130
+ r["scan_date"] = r["test_date"]
131
+ history.append(r)
132
+
133
+ history.sort(key=lambda x: x.get("scan_date") or "", reverse=True)
134
+ return history[:20]
135
+
136
+ def clear_all_history():
137
+ conn = get_connection()
138
+ conn.execute("DELETE FROM scan_history")
139
+ conn.execute("DELETE FROM test_batches")
140
+ conn.execute("DELETE FROM test_results")
141
+ conn.execute("DELETE FROM learning_data")
142
+ conn.commit()
143
+ conn.close()
144
+
145
+ def create_test_batch(username, total_images, correct_count, wrong_count, accuracy):
146
+ conn = get_connection()
147
+ cur = conn.execute('''INSERT INTO test_batches (username, total_images, correct_count, wrong_count, accuracy, test_date)
148
+ VALUES (?, ?, ?, ?, ?, ?)''',
149
+ (username, total_images, correct_count, wrong_count, accuracy, datetime.now().isoformat()))
150
+ conn.commit()
151
+ batch_id = cur.lastrowid
152
+ conn.close()
153
+ return batch_id
154
+
155
+ def save_test_result(batch_id, username, filename, folder_label, prediction, confidence, is_mismatch):
156
+ conn = get_connection()
157
+ conn.execute('''INSERT INTO test_results (batch_id, username, filename, folder_label, prediction, confidence, is_mismatch, scan_date)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
159
+ (batch_id, username, filename, folder_label, prediction, confidence, is_mismatch, datetime.now().isoformat()))
160
+ conn.commit()
161
+ conn.close()
162
+
163
+ def update_test_result_correction(result_id, user_correction, corrected_label):
164
+ conn = get_connection()
165
+ conn.execute("UPDATE test_results SET user_correction=?, corrected_label=? WHERE id=?",
166
+ (user_correction, corrected_label, result_id))
167
+ conn.commit()
168
+ conn.close()
169
+
170
+ def save_learning_data(username, filename, original_prediction, correct_label, confidence, source="user_correction"):
171
+ conn = get_connection()
172
+ conn.execute('''INSERT INTO learning_data (username, filename, original_prediction, correct_label, confidence, source, scan_date)
173
+ VALUES (?, ?, ?, ?, ?, ?, ?)''',
174
+ (username, filename, original_prediction, correct_label, confidence, source, datetime.now().isoformat()))
175
+ conn.commit()
176
+ conn.close()
177
+
178
+ def get_all_test_batches(username=None):
179
+ conn = get_connection()
180
+ if username:
181
+ rows = conn.execute("SELECT * FROM test_batches WHERE username=? ORDER BY id DESC", (username,)).fetchall()
182
+ else:
183
+ rows = conn.execute("SELECT * FROM test_batches ORDER BY id DESC").fetchall()
184
+ conn.close()
185
+ return [dict(row) for row in rows]
186
+
187
+ def get_test_results_by_batch(batch_id):
188
+ conn = get_connection()
189
+ rows = conn.execute("SELECT * FROM test_results WHERE batch_id=?", (batch_id,)).fetchall()
190
+ conn.close()
191
+ return [dict(row) for row in rows]
192
+
193
+ def get_overall_accuracy(username=None):
194
+ conn = get_connection()
195
+
196
+ total = 0
197
+ correct = 0
198
+
199
+ if username:
200
+ rows = conn.execute("SELECT * FROM test_results WHERE username=?", (username,)).fetchall()
201
+ ld_rows = conn.execute("SELECT * FROM learning_data WHERE username=? AND source='user_correction'", (username,)).fetchall()
202
+ else:
203
+ rows = conn.execute("SELECT * FROM test_results").fetchall()
204
+ ld_rows = conn.execute("SELECT * FROM learning_data WHERE source='user_correction'").fetchall()
205
+
206
+ conn.close()
207
+
208
+ for r in rows:
209
+ if not r["folder_label"]:
210
+ continue
211
+ total += 1
212
+ final_label = r["corrected_label"] or r["prediction"]
213
+ expected = "AI" if r["folder_label"].lower() in ("fake", "ai") else "REAL"
214
+ if final_label == expected:
215
+ correct += 1
216
+
217
+ for r in ld_rows:
218
+ total += 1
219
+ if r["original_prediction"] == r["correct_label"]:
220
+ correct += 1
221
+
222
+ wrong = total - correct
223
+ acc = round((correct / total * 100), 1) if total > 0 else 0
224
+ return {"total": total, "correct": correct, "wrong": wrong, "accuracy": acc}
225
+
226
+ def get_learning_data_count():
227
+ conn = get_connection()
228
+ row = conn.execute("SELECT COUNT(*) as cnt FROM learning_data").fetchone()
229
+ conn.close()
230
+ return row["cnt"] or 0
231
+
232
+ def migrate_existing_learning_data():
233
+ conn = get_connection()
234
+ rows = conn.execute('''SELECT tr.* FROM test_results tr
235
+ LEFT JOIN learning_data ld ON tr.filename = ld.filename AND tr.username = ld.username
236
+ WHERE tr.is_mismatch = 1 AND tr.folder_label IS NOT NULL AND ld.id IS NULL''').fetchall()
237
+ for r in rows:
238
+ expected = "AI" if r["folder_label"].lower() in ("fake", "ai") else "REAL"
239
+ conn.execute('''INSERT INTO learning_data (username, filename, original_prediction, correct_label, confidence, source, scan_date)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?)''',
241
+ (r["username"], r["filename"], r["prediction"], expected, r["confidence"], "batch_mismatch", r["scan_date"]))
242
+ conn.commit()
243
+ conn.close()
244
+
245
+ def sync_scan_history_to_test_results():
246
+ conn = get_connection()
247
+ # Cari seluruh scan di scan_history yang belum ada di test_results (batch_id IS NULL)
248
+ scans = conn.execute('''
249
+ SELECT sh.username, sh.filename, sh.is_ai, sh.accuracy, sh.scan_date
250
+ FROM scan_history sh
251
+ LEFT JOIN test_results tr ON sh.username = tr.username AND sh.filename = tr.filename AND tr.batch_id IS NULL
252
+ WHERE tr.id IS NULL
253
+ ''').fetchall()
254
+
255
+ for s in scans:
256
+ prediction_label = "AI" if s["is_ai"] == 1 else "REAL"
257
+
258
+ # Inferred label
259
+ inferred_label = None
260
+ fn_lower = s["filename"].lower()
261
+ if "real" in fn_lower:
262
+ inferred_label = "REAL"
263
+ elif "fake" in fn_lower or "ai" in fn_lower:
264
+ inferred_label = "AI"
265
+ else:
266
+ inferred_label = prediction_label # Default correct
267
+
268
+ is_mismatch = 1 if prediction_label != inferred_label else 0
269
+
270
+ # Cek jika ada user_correction di learning_data
271
+ ld = conn.execute("SELECT correct_label FROM learning_data WHERE username=? AND filename=? AND source='user_correction' ORDER BY id DESC LIMIT 1",
272
+ (s["username"], s["filename"])).fetchone()
273
+ corrected_label = None
274
+ if ld:
275
+ corrected_label = "AI" if ld["correct_label"].upper() in ("FAKE", "AI") else "REAL"
276
+ is_mismatch = 1 if prediction_label != corrected_label else 0
277
+ inferred_label = corrected_label
278
+
279
+ conn.execute('''INSERT INTO test_results (batch_id, username, filename, folder_label, prediction, confidence, is_mismatch, corrected_label, scan_date)
280
+ VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)''',
281
+ (s["username"], s["filename"], inferred_label, prediction_label, s["accuracy"], is_mismatch, corrected_label, s["scan_date"]))
282
+ conn.commit()
283
+ conn.close()
index.html ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI Detector Pro</title>
7
+ <link id="app-style" rel="stylesheet" href="template/style.css">
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
9
+ </head>
10
+ <body>
11
+
12
+ <!-- IKLAN PRODUK DI BACKGROUND -->
13
+ <div class="ads-container left">
14
+ <div class="ad-card">🚀 Deteksi Gambar AI<br><small id="ad-accuracy-text">Akurasi 99.9%</small></div>
15
+ <div class="ad-card">🎬 File Terdeteksi<br><small id="ad-detected-count">Total: -</small></div>
16
+ <div class="ad-card">🛡️ Amankan Bisnis Anda<br><small>Dari Konten Palsu</small></div>
17
+ </div>
18
+ <div class="ads-container right">
19
+ <div class="ad-card">⭐ Versi Premium<br><small>Bebas Limit Upload</small></div>
20
+ <div class="ad-card">📊 Dashboard Analitik<br><small>Pantau Semua Scan</small></div>
21
+ <div class="ad-card">🔗 API Access<br><small>Integrasikan ke Web Anda</small></div>
22
+ </div>
23
+
24
+ <!-- LOADING SCREEN (POLKADOT THEME) -->
25
+ <div id="loading-screen" class="polka-dot-bg">
26
+ <div class="loading-content">
27
+ <div class="logo-big">🔍</div>
28
+ <h1>AI DETECTOR PRO</h1>
29
+ <div class="loader-bar"><div class="loader-fill"></div></div>
30
+ <p>Mempersiapkan Otak Neural...</p>
31
+ </div>
32
+ </div>
33
+
34
+ <!-- HALAMAN AUTH (LOGIN & REGISTER) -->
35
+ <div id="auth-page" class="page hidden">
36
+ <!-- FEATURE ADVERTISEMENTS MELAYANG ORGANIK -->
37
+ <div class="auth-float" id="float1">🖼️ Pindai Gambar Real & AI</div>
38
+ <div class="auth-float" id="auth-detected-count">📁 - File Terdeteksi</div>
39
+ <div class="auth-float" id="auth-accuracy-text">🎯 Akurasi -</div>
40
+ <div class="auth-float" id="float4">🤖 Double Model Ensemble (v4)</div>
41
+ <div class="auth-float" id="float5">💾 Ekspor Dataset ZIP</div>
42
+ <div class="auth-float" id="float6">⚡ Respon Cepat &lt; 0.5 Detik</div>
43
+
44
+ <div class="auth-box">
45
+ <h2>🔍 AI Detector Pro</h2>
46
+ <div class="tabs">
47
+ <button id="tab-login" class="tab-btn active" onclick="switchTab('login')">Login</button>
48
+ <button id="tab-register" class="tab-btn" onclick="switchTab('register')">Register</button>
49
+ </div>
50
+
51
+ <!-- Form Login -->
52
+ <form id="form-login" class="auth-form" onsubmit="handleLogin(event)">
53
+ <input type="text" id="login-user" placeholder="Username" required>
54
+ <input type="password" id="login-pass" placeholder="Password" required>
55
+ <button type="submit" class="btn-primary">MASUK</button>
56
+ <p id="login-error" class="error-text"></p>
57
+ </form>
58
+
59
+ <!-- Form Register -->
60
+ <form id="form-register" class="auth-form hidden" onsubmit="handleRegister(event)">
61
+ <input type="text" id="reg-name" placeholder="Nama Lengkap" required>
62
+ <input type="text" id="reg-user" placeholder="Username" required>
63
+ <input type="password" id="reg-pass" placeholder="Password" required>
64
+ <button type="submit" class="btn-primary">DAFTAR</button>
65
+ <p id="reg-error" class="error-text"></p>
66
+ </form>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- HALAMAN UTAMA APLIKASI -->
71
+ <div id="main-app" class="page hidden">
72
+ <nav class="sidebar">
73
+ <div class="logo-small">🔍</div>
74
+ <ul>
75
+ <li class="nav-item active" onclick="showSection('dashboard')">📊 <span>Dashboard</span></li>
76
+ <li class="nav-item" onclick="showSection('scan-image')">🖼️ <span>Scan Gambar</span></li>
77
+ <li class="nav-item" onclick="showSection('batch-test')">📁 <span>Batch Test</span></li>
78
+ <li class="nav-item" onclick="showSection('history')">📋 <span>History</span></li>
79
+ <li class="nav-item" onclick="showSection('accuracy')">🎯 <span>Akurasi</span></li>
80
+ </ul>
81
+ <button class="btn-logout" onclick="handleLogout()">🚪 <span>Logout</span></button>
82
+ </nav>
83
+
84
+ <main class="content">
85
+ <header style="display: flex; justify-content: space-between; align-items: center;">
86
+ <div>
87
+ <h2>Selamat Datang, <span id="user-name">User</span></h2>
88
+ <div id="user-trust-container" style="margin-top: 4px; font-size: 12px; color: rgba(255,255,255,0.6); display: flex; align-items: center; gap: 6px;">
89
+ 🛡️ Skor Kredibilitas: <b id="user-trust-score" style="color: var(--yellow-main)">50</b>/100
90
+ <span id="user-trust-badge" style="font-size: 10px; font-weight: bold; padding: 2px 6px; border-radius: 4px; background: rgba(255,215,0,0.15); color: var(--yellow-main); border: 1px solid rgba(255,215,0,0.2);">Standar</span>
91
+ </div>
92
+ </div>
93
+ <div id="api-status-indicator" style="display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; padding: 6px 12px; border-radius: 20px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); transition: 0.3s;">
94
+ <span id="api-status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: #ff4757; box-shadow: 0 0 8px #ff4757; transition: 0.3s;"></span>
95
+ <span id="api-status-text" style="color: rgba(255, 255, 255, 0.75);">API Offline</span>
96
+ </div>
97
+ </header>
98
+
99
+ <!-- DASHBOARD SECTION -->
100
+ <section id="sec-dashboard" class="section active">
101
+ <div class="stats-grid" id="dashboard-stats">
102
+ <div class="stat-card blue"><h3>-</h3><p>Total Test</p></div>
103
+ <div class="stat-card yellow"><h3>-</h3><p>Benar</p></div>
104
+ <div class="stat-card blue"><h3>-</h3><p>Salah</p></div>
105
+ <div class="stat-card yellow"><h3>-</h3><p>Akurasi</p></div>
106
+ </div>
107
+ <div id="dashboard-learning" class="info-box" style="margin-bottom:20px">
108
+ Data pembelajaran: <b>-</b> gambar
109
+ </div>
110
+ <div class="info-box">
111
+ <h3>Cara Menggunakan:</h3>
112
+ <ol style="margin-bottom: 15px;">
113
+ <li><b>Scan Gambar</b> — upload 1 foto, lihat hasil REAL/AI, konfirmasi Benar/Salah</li>
114
+ <li><b>Batch Test</b> — pilih folder berisi subfolder <b>real/</b> dan <b>fake/</b>, scan massal</li>
115
+ <li><b>Akurasi</b> — lihat statistik lengkap + download feedback ZIP buat training</li>
116
+ </ol>
117
+ <div style="padding-top: 15px; border-top: 1px solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; gap: 8px;">
118
+ <span style="font-size: 16px;">🤖</span>
119
+ <span style="font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.85);">
120
+ Active Model: <span style="color: var(--yellow-main);">Ensemble v4 (Epoch 8 + Epoch 14)</span>
121
+ </span>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- DASHBOARD QUICK ACTIONS (Poin 3) -->
126
+ <div style="display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap;">
127
+ <button class="btn-scan" style="flex: 1; min-width: 200px; padding: 15px; font-size: 14px; font-weight: 700; background: linear-gradient(135deg, #1e3c72, #2a5298); color: #ffffff !important; border: 1px solid rgba(255,215,0,0.2); border-radius: 12px; box-shadow: 0 4px 15px rgba(30, 60, 114, 0.25);" onclick="showSection('scan-image')">
128
+ 🖼️ MULAI SCAN GAMBAR
129
+ </button>
130
+ <button class="btn-scan" style="flex: 1; min-width: 200px; padding: 15px; font-size: 14px; font-weight: 700; background: linear-gradient(135deg, #001f3f, #003366); color: #ffffff !important; border: 1px solid rgba(255,215,0,0.3); border-radius: 12px; box-shadow: 0 4px 15px rgba(0, 31, 63, 0.25);" onclick="showSection('batch-test')">
131
+ 📁 MULAI BATCH TEST
132
+ </button>
133
+ </div>
134
+ </section>
135
+
136
+ <!-- SCAN GAMBAR SECTION -->
137
+ <section id="sec-scan-image" class="section">
138
+ <div class="upload-container">
139
+ <input type="file" id="input-image" accept="image/png, image/jpeg, image/webp" hidden>
140
+ <div class="upload-box" onclick="document.getElementById('input-image').click()">
141
+ <span class="upload-icon">📁</span>
142
+ <p>Klik untuk upload gambar (Maks 10MB)</p>
143
+ <small id="img-name">Tidak ada file dipilih</small>
144
+ </div>
145
+ <button class="btn-scan" onclick="scanFile()">🔍 SCAN GAMBAR</button>
146
+ </div>
147
+ <div id="result-image" class="result-box hidden"></div>
148
+ </section>
149
+
150
+ <!-- BATCH TEST SECTION -->
151
+ <section id="sec-batch-test" class="section">
152
+ <div class="upload-container">
153
+ <div class="info-box" style="margin-bottom:20px">
154
+ <h3>Batch Test</h3>
155
+ <p>Pilih folder yang berisi subfolder <b>real</b> dan <b>fake</b> (atau <b>ai</b>). Sistem akan scan semua gambar dan membandingkan hasil deteksi dengan label folder.</p>
156
+ </div>
157
+ <input type="file" id="input-batch" webkitdirectory multiple hidden>
158
+ <div class="upload-box" onclick="document.getElementById('input-batch').click()">
159
+ <span class="upload-icon">📂</span>
160
+ <p>Klik untuk pilih folder</p>
161
+ <small id="batch-folder-name">Belum ada folder dipilih</small>
162
+ </div>
163
+ <div id="batch-file-list" style="width:100%;max-width:600px;margin-bottom:15px"></div>
164
+ <button class="btn-scan" onclick="startBatchScan()">🔍 MULAI BATCH TEST</button>
165
+ </div>
166
+ <div id="batch-progress" class="hidden" style="margin-top:20px;text-align:center;color:var(--yellow-main)">⏳ Memproses...</div>
167
+ <div id="batch-result" class="hidden" style="margin-top:20px"></div>
168
+ </section>
169
+
170
+ <!-- HISTORY SECTION -->
171
+ <section id="sec-history" class="section">
172
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:15px;flex-wrap:wrap">
173
+ <button class="btn-refresh" onclick="loadHistory()">🔄 Refresh History</button>
174
+ <span id="history-count" style="color:rgba(255,255,255,0.5);font-size:13px"></span>
175
+ </div>
176
+ <div class="table-wrap">
177
+ <table class="history-table">
178
+ <thead>
179
+ <tr><th>File</th><th>Tipe</th><th>Ukuran</th><th>Sumber</th><th>Akurasi</th><th>Status</th><th>Tanggal</th></tr>
180
+ </thead>
181
+ <tbody id="history-body">
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+ </section>
186
+
187
+ <!-- ACCURACY SECTION -->
188
+ <section id="sec-accuracy" class="section">
189
+ <!-- TOOLBAR AKURASI (TIME FILTER, DOWNLOAD FEEDBACK, RESET STATS) -->
190
+ <div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:center;">
191
+ <button class="btn-refresh" onclick="loadAccuracyReport()">🔄 Refresh</button>
192
+
193
+ <select id="accuracy-time-filter" onchange="loadAccuracyReport()" title="Filter Rentang Waktu" aria-label="Filter Rentang Waktu" style="padding: 10px 15px; border-radius: 8px; background: rgba(0, 31, 63, 0.7); color: white; border: 1px solid var(--yellow-main); cursor: pointer; font-size: 14px; font-weight: 600; outline: none; transition: 0.3s;">
194
+ <option value="all">📅 Semua Waktu</option>
195
+ <option value="today">📅 Hari Ini</option>
196
+ <option value="week">📅 Minggu Ini</option>
197
+ <option value="month">📅 Bulan Ini</option>
198
+ </select>
199
+
200
+ <button class="btn-scan" style="padding:10px 20px;font-size:14px;background: linear-gradient(135deg, #1e3c72, #2a5298);border: 1px solid rgba(255,215,0,0.3);" onclick="downloadFeedback()">⬇️ Download Feedback</button>
201
+ </div>
202
+
203
+ <div id="accuracy-summary" style="margin-bottom:20px"></div>
204
+
205
+ <!-- ROW 1 CHART: DONUT & STACKED BAR -->
206
+ <div class="chart-grid">
207
+ <div class="info-box"><canvas id="chart-donut" height="200"></canvas></div>
208
+ <div class="info-box"><canvas id="chart-bar" height="200"></canvas></div>
209
+ </div>
210
+
211
+ <!-- ROW 2 CHART: CONFUSION MATRIX & CONFIDENCE DISTRIBUTION CURVE -->
212
+ <div class="chart-grid" style="margin-top: 20px;">
213
+ <!-- Sisi Kiri Bawah: Confusion Matrix 2x2 -->
214
+ <div class="info-box" style="display: flex; flex-direction: column; justify-content: space-between;">
215
+ <h3 style="color: var(--yellow-main); margin-bottom: 15px; font-size: 16px; text-align: center;">📊 Confusion Matrix (2x2)</h3>
216
+ <div style="display: grid; grid-template-columns: 80px 1fr 1fr; gap: 8px; text-align: center; font-size: 12px; font-weight: bold; flex: 1; align-content: center;">
217
+ <div></div>
218
+ <div style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px; color: var(--yellow-light);">Prediksi REAL</div>
219
+ <div style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px; color: var(--yellow-light);">Prediksi AI</div>
220
+
221
+ <div style="background: rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: center; border-radius: 6px; color: var(--yellow-light); min-height: 50px;">Aktual REAL</div>
222
+ <div id="cm-tn" style="background: rgba(46, 213, 115, 0.12); border: 1px solid var(--success); color: var(--success); padding: 12px 6px; border-radius: 8px; font-size: 16px; display: flex; flex-direction: column; justify-content: center; align-items: center;">0<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">TN (True Real)</span></div>
223
+ <div id="cm-fp" style="background: rgba(255, 71, 87, 0.12); border: 1px solid var(--danger); color: var(--danger); padding: 12px 6px; border-radius: 8px; font-size: 16px; display: flex; flex-direction: column; justify-content: center; align-items: center;">0<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">FP (False AI)</span></div>
224
+
225
+ <div style="background: rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: center; border-radius: 6px; color: var(--yellow-light); min-height: 50px;">Aktual AI</div>
226
+ <div id="cm-fn" style="background: rgba(255, 71, 87, 0.12); border: 1px solid var(--danger); color: var(--danger); padding: 12px 6px; border-radius: 8px; font-size: 16px; display: flex; flex-direction: column; justify-content: center; align-items: center;">0<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">FN (False Real)</span></div>
227
+ <div id="cm-tp" style="background: rgba(46, 213, 115, 0.12); border: 1px solid var(--success); color: var(--success); padding: 12px 6px; border-radius: 8px; font-size: 16px; display: flex; flex-direction: column; justify-content: center; align-items: center;">0<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">TP (True AI)</span></div>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- Sisi Kanan Bawah: Distribusi Skor Confidence -->
232
+ <div class="info-box">
233
+ <canvas id="chart-confidence" height="200"></canvas>
234
+ </div>
235
+ </div>
236
+
237
+ <!-- NEW FAILURE LOG / TABLE RIWAYAT SALAH TEBAK -->
238
+ <div class="info-box" style="margin-top: 20px;">
239
+ <h3 style="color: var(--danger); margin-bottom: 15px; display: flex; align-items: center; gap: 8px; font-size: 16px;">
240
+ 📋 Riwayat Salah Tebak (Failure Log / Mismatch List)
241
+ </h3>
242
+ <div class="table-wrap">
243
+ <table class="history-table" style="font-size: 13px;">
244
+ <thead>
245
+ <tr>
246
+ <th>Nama File</th>
247
+ <th>Ground Truth (Aktual)</th>
248
+ <th>Prediksi Model</th>
249
+ <th>Skor Confidence</th>
250
+ <th>Tanggal</th>
251
+ </tr>
252
+ </thead>
253
+ <tbody id="failure-log-body">
254
+ <tr><td colspan="5" style="text-align:center;padding:20px;color:rgba(255,255,255,0.3)">Loading data...</td></tr>
255
+ </tbody>
256
+ </table>
257
+ </div>
258
+ </div>
259
+ </section>
260
+ </main>
261
+ </div>
262
+
263
+ <script src="script.js"></script>
264
+ </body>
265
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.95.0
2
+ uvicorn>=0.20.0
3
+ httpx>=0.24.0
4
+ Pillow>=9.0.0
5
+ jinja2>=3.0.0
6
+ python-multipart>=0.0.6
script.js ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_URL = window.location.origin;
2
+ let currentUser = "";
3
+
4
+ async function updateGlobalStats() {
5
+ try {
6
+ const url = `${API_URL}/api/accuracy-report`;
7
+ const res = await fetch(url);
8
+ const data = await res.json();
9
+ const s = data.stats;
10
+ const totalFiles = data.scan_count + data.batch_images;
11
+
12
+ const adAcc = document.getElementById("ad-accuracy-text");
13
+ if (adAcc) adAcc.innerText = `Akurasi ${s.accuracy}%`;
14
+
15
+ const adCount = document.getElementById("ad-detected-count");
16
+ if (adCount) adCount.innerText = `${totalFiles} File`;
17
+
18
+ const authCount = document.getElementById("auth-detected-count");
19
+ if (authCount) authCount.innerText = `📁 ${totalFiles} File Terdeteksi`;
20
+
21
+ const authAcc = document.getElementById("auth-accuracy-text");
22
+ if (authAcc) authAcc.innerText = `🎯 Akurasi ${s.accuracy}%`;
23
+
24
+ return { s, data, totalFiles };
25
+ } catch (e) {
26
+ console.error("Gagal update global stats:", e);
27
+ }
28
+ }
29
+
30
+ // --- 1. LOADING SCREEN LOGIC ---
31
+ window.onload = () => {
32
+ document.getElementById("loading-screen").classList.add("hidden");
33
+ document.getElementById("auth-page").classList.remove("hidden");
34
+ document.getElementById("auth-page").style.display = "block";
35
+ updateGlobalStats();
36
+ checkApiConnection();
37
+ // Dynamic connection heartbeat every 10 seconds (Poin 1)
38
+ setInterval(checkApiConnection, 10000);
39
+ };
40
+
41
+ async function checkApiConnection() {
42
+ const dot = document.getElementById("api-status-dot");
43
+ const text = document.getElementById("api-status-text");
44
+ const indicator = document.getElementById("api-status-indicator");
45
+ if (!dot || !text || !indicator) return;
46
+
47
+ try {
48
+ const res = await fetch(`${API_URL}/api/accuracy-report`);
49
+ if (res.ok) {
50
+ dot.style.background = "#2ed573";
51
+ dot.style.boxShadow = "0 0 10px #2ed573";
52
+ text.innerText = "API Online";
53
+ text.style.color = "#2ed573";
54
+ indicator.style.borderColor = "rgba(46, 213, 115, 0.3)";
55
+ } else {
56
+ throw new Error();
57
+ }
58
+ } catch (e) {
59
+ dot.style.background = "#ff4757";
60
+ dot.style.boxShadow = "0 0 10px #ff4757";
61
+ text.innerText = "API Offline";
62
+ text.style.color = "#ff4757";
63
+ indicator.style.borderColor = "rgba(255, 71, 87, 0.3)";
64
+ }
65
+ }
66
+
67
+ // --- 2. ANIMASI PARTIKEL SENTUHAN LAYAR ---
68
+ document.addEventListener("click", (e) => {
69
+ createParticles(e.clientX, e.clientY);
70
+ });
71
+
72
+ function createParticles(x, y) {
73
+ const colors = ['#FFD700', '#0052D4', '#FFFACD', '#4D8BF5'];
74
+ for (let i = 0; i < 8; i++) { // Buat 8 partikel per klik
75
+ const particle = document.createElement("div");
76
+ particle.classList.add("click-particle");
77
+ const size = Math.random() * 10 + 5; // Ukuran 5-15px
78
+ particle.style.width = `${size}px`;
79
+ particle.style.height = `${size}px`;
80
+ particle.style.left = `${x}px`;
81
+ particle.style.top = `${y}px`;
82
+ particle.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
83
+
84
+ // Arah random terbang partikel
85
+ const tx = (Math.random() - 0.5) * 150;
86
+ const ty = (Math.random() - 0.5) * 150;
87
+ particle.style.setProperty('--tx', `${tx}px`);
88
+ particle.style.setProperty('--ty', `${ty}px`);
89
+
90
+ document.body.appendChild(particle);
91
+ setTimeout(() => particle.remove(), 800); // Hapus partikel setelah animasi selesai
92
+ }
93
+ }
94
+
95
+ // --- 3. AUTH LOGIC ---
96
+ function switchTab(tab) {
97
+ document.getElementById("form-login").classList.toggle("hidden", tab !== "login");
98
+ document.getElementById("form-register").classList.toggle("hidden", tab !== "register");
99
+ document.getElementById("tab-login").classList.toggle("active", tab === "login");
100
+ document.getElementById("tab-register").classList.toggle("active", tab === "register");
101
+ }
102
+
103
+ function updateUserTrustScoreUI(score) {
104
+ const scoreEl = document.getElementById("user-trust-score");
105
+ const badgeEl = document.getElementById("user-trust-badge");
106
+ if (!scoreEl || !badgeEl) return;
107
+
108
+ scoreEl.innerText = score;
109
+
110
+ if (score >= 80) {
111
+ badgeEl.innerText = "🛡️ Pakar";
112
+ badgeEl.style.background = "rgba(46, 204, 113, 0.15)";
113
+ badgeEl.style.color = "var(--success)";
114
+ badgeEl.style.borderColor = "rgba(46, 204, 113, 0.25)";
115
+ } else if (score < 50) {
116
+ badgeEl.innerText = "⚠️ Dicurigai";
117
+ badgeEl.style.background = "rgba(255, 71, 87, 0.15)";
118
+ badgeEl.style.color = "var(--danger)";
119
+ badgeEl.style.borderColor = "rgba(255, 71, 87, 0.25)";
120
+ } else {
121
+ badgeEl.innerText = "Standar";
122
+ badgeEl.style.background = "rgba(255, 215, 0, 0.15)";
123
+ badgeEl.style.color = "var(--yellow-main)";
124
+ badgeEl.style.borderColor = "rgba(255, 215, 0, 0.25)";
125
+ }
126
+ }
127
+
128
+ async function handleLogin(e) {
129
+ e.preventDefault();
130
+ const u = document.getElementById("login-user").value;
131
+ const p = document.getElementById("login-pass").value;
132
+ const formData = new URLSearchParams({ username: u, password: p });
133
+
134
+ try {
135
+ const res = await fetch(`${API_URL}/api/login`, { method: "POST", body: formData });
136
+ const data = await res.json();
137
+ if (res.ok) {
138
+ currentUser = data.username;
139
+ document.getElementById("user-name").innerText = data.name;
140
+ updateUserTrustScoreUI(data.trust_score || 50);
141
+ document.getElementById("auth-page").classList.add("hidden");
142
+ document.getElementById("main-app").classList.remove("hidden");
143
+ document.getElementById("app-style").href = "style.css";
144
+ loadDashboard();
145
+ loadHistory();
146
+ } else {
147
+ document.getElementById("login-error").innerText = data.detail;
148
+ }
149
+ } catch (err) {
150
+ alert("Gagal terhubung ke server Backend! Pastikan uvicorn backend:app --reload sedang berjalan.");
151
+ }
152
+ }
153
+
154
+ async function handleRegister(e) {
155
+ e.preventDefault();
156
+ const n = document.getElementById("reg-name").value;
157
+ const u = document.getElementById("reg-user").value;
158
+ const p = document.getElementById("reg-pass").value;
159
+ const formData = new URLSearchParams({ name: n, username: u, password: p });
160
+
161
+ const res = await fetch(`${API_URL}/api/register`, { method: "POST", body: formData });
162
+ const data = await res.json();
163
+ if (res.ok) {
164
+ alert("Registrasi berhasil! Silakan login.");
165
+ switchTab('login');
166
+ } else {
167
+ document.getElementById("reg-error").innerText = data.detail;
168
+ }
169
+ }
170
+
171
+ function handleLogout() {
172
+ currentUser = "";
173
+ document.getElementById("main-app").classList.add("hidden");
174
+ document.getElementById("auth-page").classList.remove("hidden");
175
+ document.getElementById("app-style").href = "template/style.css";
176
+ }
177
+
178
+ // --- 4. NAVIGATION LOGIC ---
179
+ function showSection(sectionId) {
180
+ document.querySelectorAll(".section").forEach(s => s.classList.remove("active"));
181
+ document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
182
+ document.getElementById(`sec-${sectionId}`).classList.add("active");
183
+
184
+ // Safe sidebar nav item highlight resolution (works from sidebar and shortcuts!)
185
+ const navItems = document.querySelectorAll(".nav-item");
186
+ navItems.forEach(n => {
187
+ if (n.getAttribute("onclick") && n.getAttribute("onclick").includes(`'${sectionId}'`)) {
188
+ n.classList.add("active");
189
+ }
190
+ });
191
+
192
+ if (sectionId === "dashboard") loadDashboard();
193
+ if (sectionId === "history") loadHistory();
194
+ if (sectionId === "accuracy") loadAccuracyReport();
195
+ }
196
+
197
+ async function loadDashboard() {
198
+ if (!currentUser) return;
199
+ try {
200
+ const statsData = await updateGlobalStats();
201
+ if (!statsData) return;
202
+ const { s, data } = statsData;
203
+ const accColor = s.accuracy >= 70 ? "var(--success)" : s.accuracy >= 40 ? "var(--blue-dark)" : "var(--danger)";
204
+ document.getElementById("dashboard-stats").innerHTML = `
205
+ <div class="stat-card blue"><h3>${s.total}</h3><p>Total Test</p></div>
206
+ <div class="stat-card yellow"><h3>${s.correct}</h3><p>Benar</p></div>
207
+ <div class="stat-card blue"><h3>${s.wrong}</h3><p>Salah</p></div>
208
+ <div class="stat-card yellow"><h3 style="color:${accColor}">${s.accuracy}%</h3><p>Akurasi</p></div>`;
209
+ document.getElementById("dashboard-learning").innerHTML = `
210
+ Data pembelajaran: <b style="color:var(--yellow-main)">${data.learning_data_count}</b> gambar siap training
211
+ &nbsp; <button class="btn-scan" style="padding:5px 15px;font-size:12px" onclick="showSection('accuracy');event.target.blur()">Detail →</button>`;
212
+ } catch (e) { }
213
+ }
214
+
215
+ // --- 5. FILE SCANNING LOGIC ---
216
+ document.getElementById("input-image").addEventListener("change", (e) => {
217
+ document.getElementById("img-name").innerText = e.target.files[0]?.name || "Tidak ada file dipilih";
218
+ });
219
+
220
+ async function scanFile() {
221
+ const fileInput = document.getElementById("input-image");
222
+ const resultBox = document.getElementById("result-image");
223
+
224
+ if (!fileInput.files[0]) return alert("Pilih file terlebih dahulu!");
225
+
226
+ const file = fileInput.files[0];
227
+ if (file.size > 10 * 1024 * 1024) return alert("Ukuran file melebihi batas 10MB!");
228
+
229
+ resultBox.classList.remove("hidden", "ai", "real");
230
+ resultBox.innerHTML = "<h3 style='color: var(--yellow-main)'>⏳ Menganalisis file... Mohon tunggu.</h3>";
231
+
232
+ const formData = new FormData();
233
+ formData.append("file", file);
234
+ formData.append("username", currentUser);
235
+
236
+ try {
237
+ const res = await fetch(`${API_URL}/api/scan-image`, { method: "POST", body: formData });
238
+ const data = await res.json();
239
+
240
+ if (res.ok) {
241
+ const statusClass = data.is_ai ? "ai" : "real";
242
+ const statusText = data.is_ai ? "⚠️ GAMBAR PALSU (AI)" : "✅ GAMBAR ASLI (REAL)";
243
+ const statusColor = data.is_ai ? "var(--danger)" : "var(--success)";
244
+
245
+ resultBox.className = `result-box ${statusClass}`;
246
+ resultBox.innerHTML = `
247
+ <div class="result-title" style="color: ${statusColor}">
248
+ <span>${statusText}</span>
249
+ <span>Akurasi: ${data.accuracy}%</span>
250
+ </div>
251
+ <div class="badge-bar" style="display:flex;gap:8px;margin-bottom:15px;flex-wrap:wrap">
252
+ <span class="badge" style="background:rgba(255,215,0,0.1);color:var(--yellow-main);border:1px solid rgba(255,215,0,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">
253
+ 🧬 Cosine Similarity: ${(data.similarity * 100).toFixed(1)}%
254
+ </span>
255
+ ${data.is_outlier
256
+ ? `<span class="badge" style="background:rgba(255,71,87,0.1);color:var(--danger);border:1px solid rgba(255,71,87,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">💡 Data Asing (Outlier)</span>`
257
+ : `<span class="badge" style="background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">🎯 Klasifikasi Aman</span>`
258
+ }
259
+ ${data.is_trap
260
+ ? `<span class="badge" style="background:rgba(230,126,34,0.15);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">⚠️ Trap Image Mode</span>`
261
+ : ''
262
+ }
263
+ ${data.is_dark
264
+ ? `<span class="badge" style="background:rgba(230,126,34,0.1);color:#e67e22;border:1px solid rgba(230,126,34,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">🌙 Low Light (Cahaya Rendah)</span>`
265
+ : ''
266
+ }
267
+ ${data.is_grayscale
268
+ ? `<span class="badge" style="background:rgba(149,165,166,0.15);color:#bdc3c7;border:1px solid rgba(149,165,166,0.25);padding:4px 10px;border-radius:20px;font-size:11px;font-weight:700;display:inline-flex;align-items:center;gap:4px">⚪ Monokrom (Hitam Putih)</span>`
269
+ : ''
270
+ }
271
+ </div>
272
+
273
+ ${(data.is_dark || data.is_grayscale)
274
+ ? `
275
+ <div style="background: rgba(230,126,34,0.1); border: 1px dashed rgba(230,126,34,0.3); border-radius: 8px; padding: 12px; margin-bottom: 15px; font-size: 12px; color: #f39c12; line-height: 1.5; text-align: left;">
276
+ <b>⚠️ Rekomendasi Kondisi Deteksi:</b><br/>
277
+ ${data.is_dark ? '• Cahaya terdeteksi rendah (kecerahan rata-rata: ' + data.avg_brightness + '/255). Hal ini memicu noise sensor kamera yang dapat mengganggu keakuratan forensik AI.<br/>' : ''}
278
+ ${data.is_grayscale ? '• Gambar monokrom/hitam-putih terdeteksi. Kehilangan informasi kromatik (saluran warna RGB) secara drastis dapat menurunkan performa klasifikasi model AI.<br/>' : ''}
279
+ <i style="display:block;margin-top:6px;color:rgba(255,255,255,0.7)">Disarankan untuk melakukan scan ulang menggunakan foto dengan pencahayaan cukup dan penuh warna (Full RGB).</i>
280
+ </div>
281
+ `
282
+ : ''
283
+ }
284
+
285
+ <div class="details-grid">
286
+ <div class="detail-item">
287
+ <div class="detail-label">Nama File</div>
288
+ <div class="detail-value">${data.filename}</div>
289
+ </div>
290
+ <div class="detail-item">
291
+ <div class="detail-label">Tipe</div>
292
+ <div class="detail-value">${data.type.toUpperCase()}</div>
293
+ </div>
294
+ <div class="detail-item">
295
+ <div class="detail-label">Ukuran File</div>
296
+ <div class="detail-value">${data.file_size}</div>
297
+ </div>
298
+ <div class="detail-item">
299
+ <div class="detail-label">Sumber Deteksi</div>
300
+ <div class="detail-value" style="font-size:12px">${data.source}</div>
301
+ </div>
302
+ <div class="detail-item">
303
+ <div class="detail-label">Tanggal Scan</div>
304
+ <div class="detail-value">${data.date}</div>
305
+ </div>
306
+ <div class="detail-item">
307
+ <div class="detail-label">Keakuratan</div>
308
+ <div class="detail-value" style="color: ${statusColor}">${data.accuracy}%</div>
309
+ </div>
310
+ </div>
311
+ <div style="margin-top:15px;padding-top:15px;border-top:1px solid rgba(255,255,255,0.2)">
312
+ <p style="margin-bottom:10px;color:var(--yellow-main)">Apakah hasil ini benar?</p>
313
+ <button class="btn-scan" style="padding:8px 25px;font-size:14px;margin-right:10px" onclick="confirmSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">✅ Benar</button>
314
+ <button class="btn-scan" style="padding:8px 25px;font-size:14px" onclick="correctSingleResult('${data.filename}', '${data.is_ai ? "AI" : "REAL"}', '${data.accuracy}', '${data.feedback_id || ""}', this)">❌ Salah</button>
315
+ <div id="single-correction-area" style="margin-top:10px"></div>
316
+ </div>
317
+ `;
318
+ updateUserTrustScoreUI(data.trust_score || 50);
319
+ } else {
320
+ resultBox.innerHTML = `<h3 style="color: var(--danger)">Error: ${data.detail}</h3>`;
321
+ }
322
+ } catch (err) {
323
+ resultBox.innerHTML = "<h3 style='color: var(--danger)'>Gagal terhubung ke server Backend.</h3>";
324
+ }
325
+ }
326
+
327
+ function confirmSingleResult(filename, prediction, accuracy, feedbackId, btn) {
328
+ fetch(`${API_URL}/api/correction-single`, {
329
+ method: "POST",
330
+ headers: { "Content-Type": "application/json" },
331
+ body: JSON.stringify({
332
+ username: currentUser,
333
+ filename: filename,
334
+ original_prediction: prediction,
335
+ correct_label: prediction,
336
+ confidence: parseFloat(accuracy),
337
+ feedback_id: feedbackId
338
+ })
339
+ })
340
+ .then(res => res.json())
341
+ .then(data => {
342
+ updateGlobalStats();
343
+ if (data.is_trap) {
344
+ updateUserTrustScoreUI(data.new_trust);
345
+ const scoreDiff = data.trust_change > 0 ? `+${data.trust_change}` : `${data.trust_change}`;
346
+ const statusIcon = data.trap_correct ? "🎉 BENAR!" : "❌ SALAH!";
347
+ alert(`🛡️ TRAP IMAGE DETECTED!\nFeedback Anda ${statusIcon}\nSkor Kredibilitas Anda: ${scoreDiff} (Sekarang: ${data.new_trust}/100)`);
348
+ }
349
+ })
350
+ .catch(() => { });
351
+ btn.closest("div").innerHTML = "<p style='color:var(--success)'>✅ Konfirmasi tersimpan!</p>";
352
+ }
353
+
354
+ function correctSingleResult(filename, prediction, accuracy, feedbackId, btn) {
355
+ const correctLabel = prediction === "AI" ? "REAL" : "AI";
356
+ fetch(`${API_URL}/api/correction-single`, {
357
+ method: "POST",
358
+ headers: { "Content-Type": "application/json" },
359
+ body: JSON.stringify({
360
+ username: currentUser,
361
+ filename: filename,
362
+ original_prediction: prediction,
363
+ correct_label: correctLabel,
364
+ confidence: parseFloat(accuracy),
365
+ feedback_id: feedbackId
366
+ })
367
+ })
368
+ .then(res => res.json())
369
+ .then(data => {
370
+ updateGlobalStats();
371
+ if (data.is_trap) {
372
+ updateUserTrustScoreUI(data.new_trust);
373
+ const scoreDiff = data.trust_change > 0 ? `+${data.trust_change}` : `${data.trust_change}`;
374
+ const statusIcon = data.trap_correct ? "🎉 BENAR!" : "❌ SALAH!";
375
+ alert(`🛡️ TRAP IMAGE DETECTED!\nFeedback Anda ${statusIcon}\nSkor Kredibilitas Anda: ${scoreDiff} (Sekarang: ${data.new_trust}/100)`);
376
+ }
377
+ })
378
+ .catch(() => { });
379
+ btn.closest("div").innerHTML = `<p style='color:var(--success)'>✅ Koreksi tersimpan! (seharusnya ${correctLabel})</p>`;
380
+ }
381
+
382
+ // --- 7. BATCH TEST LOGIC ---
383
+ let batchFiles = [];
384
+ let batchLabels = [];
385
+
386
+ document.getElementById("input-batch").addEventListener("change", (e) => {
387
+ const files = e.target.files;
388
+ if (!files.length) return;
389
+
390
+ const folderName = files[0].webkitRelativePath.split('/')[0];
391
+ document.getElementById("batch-folder-name").innerText = folderName;
392
+
393
+ batchFiles = [];
394
+ batchLabels = [];
395
+ const listDiv = document.getElementById("batch-file-list");
396
+ listDiv.innerHTML = "<h4 style='margin-bottom:10px'>File ditemukan:</h4>";
397
+
398
+ const table = document.createElement("table");
399
+ table.className = "history-table";
400
+ table.innerHTML = `<thead><tr><th>File</th><th>Folder</th></tr></thead><tbody></tbody>`;
401
+ const tbody = table.querySelector("tbody");
402
+
403
+ for (let f of files) {
404
+ const parts = f.webkitRelativePath.split('/');
405
+ const label = parts.length > 1 ? parts[parts.length - 2] : "";
406
+ if (!f.name.match(/\.(png|jpg|jpeg|webp)$/i)) continue;
407
+
408
+ // Normalize folder label for display consistency (Poin 2)
409
+ const labelUpper = label.toUpperCase();
410
+ const normLabel = (labelUpper === "FAKE" || labelUpper === "AI") ? "AI" : (labelUpper === "REAL" ? "REAL" : labelUpper);
411
+
412
+ batchFiles.push(f);
413
+ batchLabels.push(normLabel);
414
+
415
+ const tr = document.createElement("tr");
416
+ const color = normLabel === "REAL" ? "var(--success)" :
417
+ (normLabel === "AI" || normLabel === "FAKE") ? "var(--danger)" : "var(--yellow-main)";
418
+ tr.innerHTML = `<td>${f.name}</td><td style="color:${color};font-weight:bold">${normLabel || "-"}</td>`;
419
+ tbody.appendChild(tr);
420
+ }
421
+
422
+ listDiv.appendChild(table);
423
+ listDiv.innerHTML += `<p style="margin-top:10px;color:var(--yellow-main)">Total: <b>${batchFiles.length}</b> gambar</p>`;
424
+ });
425
+
426
+ async function startBatchScan() {
427
+ if (!batchFiles.length) return alert("Pilih folder terlebih dahulu!");
428
+ if (!currentUser) return alert("Login dulu!");
429
+
430
+ const progress = document.getElementById("batch-progress");
431
+ const resultDiv = document.getElementById("batch-result");
432
+ const confirmArea = document.getElementById("batch-confirm-area");
433
+ progress.classList.remove("hidden");
434
+ resultDiv.classList.add("hidden");
435
+ if (confirmArea) confirmArea.classList.add("hidden");
436
+ progress.innerHTML = "⏳ Mengirim file ke server...";
437
+
438
+ const formData = new FormData();
439
+ for (let f of batchFiles) {
440
+ formData.append("files", f);
441
+ }
442
+ formData.append("username", currentUser);
443
+ // Convert to robust filename -> label map to prevent any index alignment shifts
444
+ const labelMap = {};
445
+ for (let i = 0; i < batchFiles.length; i++) {
446
+ labelMap[batchFiles[i].name] = batchLabels[i];
447
+ }
448
+ formData.append("labels", JSON.stringify(labelMap));
449
+
450
+ try {
451
+ const res = await fetch(`${API_URL}/api/batch-scan`, { method: "POST", body: formData });
452
+ const data = await res.json();
453
+ progress.classList.add("hidden");
454
+
455
+ if (!res.ok) {
456
+ resultDiv.innerHTML = `<h3 style="color:var(--danger)">Error: ${data.detail}</h3>`;
457
+ resultDiv.classList.remove("hidden");
458
+ return;
459
+ }
460
+
461
+ displayBatchResults(data, resultDiv, confirmArea);
462
+ updateGlobalStats();
463
+ } catch (err) {
464
+ progress.innerHTML = "<h3 style='color:var(--danger)'>Gagal terhubung ke server.</h3>";
465
+ }
466
+ }
467
+
468
+ function displayBatchResults(data, resultDiv, confirmArea) {
469
+ const color = data.accuracy >= 70 ? "var(--success)" : data.accuracy >= 40 ? "var(--blue-dark)" : "var(--danger)";
470
+
471
+ let html = `
472
+ <div class="result-box" style="border-color:${color}">
473
+ <div class="result-title" style="color:${color}; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
474
+ <div>
475
+ <span>Hasil Batch Test</span>
476
+ <span style="font-size: 12px; background: rgba(255, 215, 0, 0.15); color: var(--yellow-main); padding: 4px 8px; border-radius: 6px; margin-left: 10px; font-weight: normal; border: 1px solid rgba(255, 215, 0, 0.3)">Using Threshold: 0.50</span>
477
+ </div>
478
+ <span>Akurasi: ${data.accuracy}%</span>
479
+ </div>
480
+ <div class="details-grid">
481
+ <div class="detail-item">
482
+ <div class="detail-label">Total Gambar</div>
483
+ <div class="detail-value">${data.total}</div>
484
+ </div>
485
+ <div class="detail-item">
486
+ <div class="detail-label">Benar</div>
487
+ <div class="detail-value" style="color:var(--success)">${data.correct}</div>
488
+ </div>
489
+ <div class="detail-item">
490
+ <div class="detail-label">Salah</div>
491
+ <div class="detail-value" style="color:var(--danger)">${data.wrong}</div>
492
+ </div>
493
+ <div class="detail-item">
494
+ <div class="detail-label">Akurasi</div>
495
+ <div class="detail-value" style="color:${color}">${data.accuracy}%</div>
496
+ </div>
497
+ </div>
498
+ </div>
499
+ <div class="table-wrap" style="margin-top:15px">
500
+ <table class="history-table">
501
+ <thead><tr><th>File</th><th>Folder</th><th>Prediksi</th><th>Confidence</th><th>Status</th></tr></thead>
502
+ <tbody>`;
503
+
504
+ data.results.forEach((r, i) => {
505
+ if (r.error) {
506
+ html += `<tr><td>${r.filename}</td><td colspan="4" style="color:var(--danger)">Error: ${r.error}</td></tr>`;
507
+ return;
508
+ }
509
+ const statusColor = r.is_mismatch ? "var(--danger)" : "var(--success)";
510
+ const statusText = r.is_mismatch ? "❌ SALAH" : "✅ BENAR";
511
+
512
+ // Capitalize folder label consistently (Poin 2)
513
+ const folderUpper = r.folder_label ? r.folder_label.toUpperCase() : "-";
514
+ const folderColor = folderUpper === "REAL" ? "var(--success)" :
515
+ (folderUpper === "AI" || folderUpper === "FAKE") ? "var(--danger)" : "var(--yellow-main)";
516
+
517
+ const predColor = r.prediction === "REAL" ? "var(--success)" : "var(--danger)";
518
+
519
+ // Highlight incorrect rows (Poin 3)
520
+ const rowBg = r.is_mismatch ? "background: rgba(255, 71, 87, 0.08);" : "";
521
+
522
+ html += `<tr style="${rowBg}">
523
+ <td>${r.filename}</td>
524
+ <td style="color:${folderColor};font-weight:bold">${folderUpper}</td>
525
+ <td style="color:${predColor};font-weight:bold">${r.prediction}</td>
526
+ <td style="color:var(--yellow-main)">${r.confidence}%</td>
527
+ <td style="color:${statusColor};font-weight:bold">${statusText}</td>
528
+ </tr>`;
529
+ });
530
+
531
+ html += `</tbody></table></div>`;
532
+ html += `<div style="margin-top:15px;text-align:center"><button class="btn-scan" onclick="showSection('accuracy');event.target.closest('#batch-result .btn-scan').remove()" style="padding:10px 30px;font-size:14px">📊 Lihat Akurasi →</button></div>`;
533
+ resultDiv.innerHTML = html;
534
+ resultDiv.classList.remove("hidden");
535
+
536
+ }
537
+
538
+ // --- 7. ACCURACY REPORT ---
539
+ async function loadAccuracyReport() {
540
+ if (!currentUser) return;
541
+
542
+ const summaryDiv = document.getElementById("accuracy-summary");
543
+ const timeFilter = document.getElementById("accuracy-time-filter")?.value || "all";
544
+
545
+ try {
546
+ const res = await fetch(`${API_URL}/api/accuracy-report?filter=${timeFilter}`);
547
+ const report = await res.json();
548
+
549
+ const s = report.stats;
550
+ const matrix = report.confusion_matrix;
551
+ const dist = report.confidence_distribution;
552
+ const failures = report.failures;
553
+
554
+ const textColor = s.accuracy >= 70 ? "var(--success)" : s.accuracy >= 40 ? "var(--yellow-main)" : "var(--danger)";
555
+
556
+ // 1. Render Summary stats & Advanced metrics
557
+ summaryDiv.innerHTML = `
558
+ <!-- Primary Stats -->
559
+ <div class="stats-grid">
560
+ <div class="stat-card blue"><h3>${s.total}</h3><p>Total Test</p></div>
561
+ <div class="stat-card yellow"><h3>${s.correct}</h3><p>Benar</p></div>
562
+ <div class="stat-card blue"><h3>${s.wrong}</h3><p>Salah</p></div>
563
+ <div class="stat-card yellow"><h3 style="color:${textColor}">${s.accuracy}%</h3><p>Akurasi</p></div>
564
+ </div>
565
+
566
+ <!-- Advanced ML Metrics -->
567
+ <div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); margin-top: 15px;">
568
+ <div class="stat-card" style="background: #002244 !important; border: 1px solid rgba(255, 215, 0, 0.35) !important; border-left: 4px solid var(--yellow-main) !important; padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25) !important;">
569
+ <h3 style="font-size: 24px; color: #FFD700 !important; font-weight: 800; margin: 0; opacity: 1 !important;">${s.precision}%</h3>
570
+ <p style="font-size: 11px; margin: 6px 0 0 0; color: #ffffff !important; opacity: 1 !important; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">Precision (Presisi)</p>
571
+ </div>
572
+ <div class="stat-card" style="background: #002244 !important; border: 1px solid rgba(255, 215, 0, 0.35) !important; border-left: 4px solid var(--yellow-main) !important; padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25) !important;">
573
+ <h3 style="font-size: 24px; color: #FFD700 !important; font-weight: 800; margin: 0; opacity: 1 !important;">${s.recall}%</h3>
574
+ <p style="font-size: 11px; margin: 6px 0 0 0; color: #ffffff !important; opacity: 1 !important; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">Recall (Sensitivitas)</p>
575
+ </div>
576
+ <div class="stat-card" style="background: #002244 !important; border: 1px solid rgba(255, 215, 0, 0.35) !important; border-left: 4px solid var(--yellow-main) !important; padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25) !important;">
577
+ <h3 style="font-size: 24px; color: #FFD700 !important; font-weight: 800; margin: 0; opacity: 1 !important;">${s.f1_score}%</h3>
578
+ <p style="font-size: 11px; margin: 6px 0 0 0; color: #ffffff !important; opacity: 1 !important; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">F1-Score (Harmonis)</p>
579
+ </div>
580
+ </div>
581
+
582
+ <!-- Info Box -->
583
+ <div class="info-box" style="margin-top: 15px;">
584
+ <p>Batch test: <b style="color:var(--yellow-main)">${report.batch_images}</b> gambar</p>
585
+ <p>Scan individu: <b style="color:var(--yellow-main)">${report.scan_count}</b> gambar</p>
586
+ <p>Total data pembelajaran: <b style="color:var(--yellow-main)">${report.learning_data_count}</b> gambar (siap training)</p>
587
+ </div>
588
+ `;
589
+
590
+ // 2. Render Confusion Matrix Values
591
+ document.getElementById("cm-tp").innerHTML = `${matrix.tp}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">TP (True AI)</span>`;
592
+ document.getElementById("cm-fp").innerHTML = `${matrix.fp}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">FP (False AI)</span>`;
593
+ document.getElementById("cm-fn").innerHTML = `${matrix.fn}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">FN (False Real)</span>`;
594
+ document.getElementById("cm-tn").innerHTML = `${matrix.tn}<br><span style="font-size: 9px; opacity: 0.8; font-weight: normal; margin-top: 3px;">TN (True Real)</span>`;
595
+
596
+ // 3. Render Failure Log Table
597
+ const failureBody = document.getElementById("failure-log-body");
598
+ failureBody.innerHTML = "";
599
+ if (failures.length === 0) {
600
+ failureBody.innerHTML = "<tr><td colspan='5' style='text-align:center;padding:20px;color:rgba(255,255,255,0.3)'>Tidak ada kesalahan tebak terdeteksi dalam data pengetesan ini! 🎉</td></tr>";
601
+ } else {
602
+ failures.forEach(f => {
603
+ const expColor = f.expected === 'AI' ? 'var(--danger)' : 'var(--success)';
604
+ const predColor = f.prediction === 'AI' ? 'var(--danger)' : 'var(--success)';
605
+ failureBody.innerHTML += `
606
+ <tr>
607
+ <td>${f.filename}</td>
608
+ <td><span style="color: ${expColor}; font-weight:bold">${f.expected}</span></td>
609
+ <td><span style="color: ${predColor}">${f.prediction}</span></td>
610
+ <td style="color: var(--yellow-main); font-weight:bold">${f.confidence}%</td>
611
+ <td style="font-size: 11px; opacity: 0.7;">${f.date}</td>
612
+ </tr>
613
+ `;
614
+ });
615
+ }
616
+
617
+ // 4. Render Charts
618
+ drawDonutChart(s.correct, s.wrong);
619
+ drawBarChart(report.batches);
620
+ drawConfidenceChart(dist);
621
+ } catch (err) {
622
+ console.error("Gagal memuat accuracy report:", err);
623
+ }
624
+ }
625
+
626
+ let chartDonut = null, chartBar = null, chartConfidence = null;
627
+
628
+ function drawDonutChart(correct, wrong) {
629
+ const ctx = document.getElementById("chart-donut").getContext("2d");
630
+ if (chartDonut) chartDonut.destroy();
631
+ if (correct + wrong === 0) return;
632
+ chartDonut = new Chart(ctx, {
633
+ type: "doughnut",
634
+ data: {
635
+ labels: ["Benar", "Salah"],
636
+ datasets: [{
637
+ data: [correct, wrong],
638
+ backgroundColor: ["#2ed573", "#ff4757"],
639
+ borderWidth: 0
640
+ }]
641
+ },
642
+ options: {
643
+ responsive: true, maintainAspectRatio: false,
644
+ plugins: {
645
+ title: { display: true, text: "Perbandingan Benar vs Salah", color: "#FFD700" },
646
+ legend: { labels: { color: "#fff" } }
647
+ }
648
+ }
649
+ });
650
+ }
651
+
652
+ function drawBarChart(batches) {
653
+ const ctx = document.getElementById("chart-bar").getContext("2d");
654
+ if (chartBar) chartBar.destroy();
655
+ if (!batches.length) return;
656
+ chartBar = new Chart(ctx, {
657
+ type: "bar",
658
+ data: {
659
+ labels: batches.slice(0, 10).map(b => "#" + b.id), // limit to latest 10 batches
660
+ datasets: [
661
+ { label: "Benar", data: batches.slice(0, 10).map(b => b.correct_count), backgroundColor: "#2ed573" },
662
+ { label: "Salah", data: batches.slice(0, 10).map(b => b.wrong_count), backgroundColor: "#ff4757" }
663
+ ]
664
+ },
665
+ options: {
666
+ responsive: true, maintainAspectRatio: false,
667
+ scales: {
668
+ x: { ticks: { color: "#fff" }, stacked: true },
669
+ y: { ticks: { color: "#fff" }, stacked: true }
670
+ },
671
+ plugins: {
672
+ title: { display: true, text: "Akurasi per Batch Test (10 Terakhir)", color: "#FFD700" },
673
+ legend: { labels: { color: "#fff" } }
674
+ }
675
+ }
676
+ });
677
+ }
678
+
679
+ function drawConfidenceChart(dist) {
680
+ const ctx = document.getElementById("chart-confidence").getContext("2d");
681
+ if (chartConfidence) chartConfidence.destroy();
682
+ chartConfidence = new Chart(ctx, {
683
+ type: "line",
684
+ data: {
685
+ labels: dist.buckets,
686
+ datasets: [
687
+ {
688
+ label: "REAL Predictions",
689
+ data: dist.real,
690
+ borderColor: "#2ed573",
691
+ backgroundColor: "rgba(46, 213, 115, 0.1)",
692
+ fill: true,
693
+ tension: 0.4
694
+ },
695
+ {
696
+ label: "AI Predictions",
697
+ data: dist.ai,
698
+ borderColor: "#ff4757",
699
+ backgroundColor: "rgba(255, 71, 87, 0.1)",
700
+ fill: true,
701
+ tension: 0.4
702
+ }
703
+ ]
704
+ },
705
+ options: {
706
+ responsive: true, maintainAspectRatio: false,
707
+ scales: {
708
+ x: { ticks: { color: "#fff" }, grid: { color: "rgba(255,255,255,0.05)" } },
709
+ y: { ticks: { color: "#fff" }, grid: { color: "rgba(255,255,255,0.05)" }, beginAtZero: true }
710
+ },
711
+ plugins: {
712
+ title: { display: true, text: "Sebaran Skor Keyakinan (Confidence)", color: "#FFD700" },
713
+ legend: { labels: { color: "#fff" } }
714
+ }
715
+ }
716
+ });
717
+ }
718
+
719
+ async function confirmClearHistory() {
720
+ if (!confirm("⚠️ PERINGATAN: Apakah Anda yakin ingin menghapus seluruh riwayat scan dan merestart semua statistik pengujian kembali ke angka nol? Tindakan ini tidak dapat dibatalkan!")) {
721
+ return;
722
+ }
723
+ try {
724
+ const res = await fetch(`${API_URL}/api/clear-history`);
725
+ const data = await res.json();
726
+ if (res.ok) {
727
+ alert("Statistik berhasil direset ke nol!");
728
+ loadAccuracyReport();
729
+ loadHistory();
730
+ updateGlobalStats();
731
+ }
732
+ } catch (e) {
733
+ alert("Gagal mereset statistik.");
734
+ }
735
+ }
736
+
737
+ function downloadFeedback() {
738
+ window.open(`${API_URL}/api/download-feedback`, "_blank");
739
+ }
740
+ async function loadHistory() {
741
+ if (!currentUser) return;
742
+ const tbody = document.getElementById("history-body");
743
+ const count = document.getElementById("history-count");
744
+ tbody.innerHTML = "<tr><td colspan='7' style='text-align:center;padding:25px;color:rgba(255,255,255,0.3)'>Loading...</td></tr>";
745
+
746
+ try {
747
+ const res = await fetch(`${API_URL}/api/history/${currentUser}`);
748
+ const data = await res.json();
749
+ tbody.innerHTML = "";
750
+ count.innerText = `${data.history.length} item`;
751
+
752
+ data.history.forEach(item => {
753
+ if (item._type === "batch") {
754
+ const batchColor = item.accuracy >= 70 ? "var(--success)" : item.accuracy >= 40 ? "var(--yellow-main)" : "var(--danger)";
755
+ tbody.innerHTML += `
756
+ <tr style="background:rgba(255,215,0,0.05)">
757
+ <td style="color:var(--yellow-main)">${item.filename}</td>
758
+ <td>${item.file_type}</td>
759
+ <td>${item.file_size}</td>
760
+ <td style="font-size:12px">${item.source}</td>
761
+ <td style="color: ${batchColor}; font-weight:bold">${item.accuracy}%</td>
762
+ <td style="color: ${batchColor}; font-weight:bold">${item.accuracy >= 70 ? '✅ Baik' : item.accuracy >= 40 ? '⚠️ Sedang' : '❌ Buruk'}</td>
763
+ <td>${item.scan_date}</td>
764
+ </tr>`;
765
+ } else {
766
+ const statusColor = item.is_ai ? "var(--danger)" : "var(--success)";
767
+ const statusText = item.is_ai ? "Palsu (AI)" : "Asli (Real)";
768
+ tbody.innerHTML += `
769
+ <tr>
770
+ <td>${item.filename}</td>
771
+ <td>${item.file_type}</td>
772
+ <td>${item.file_size}</td>
773
+ <td style="font-size:12px">${item.source}</td>
774
+ <td style="color: var(--yellow-main); font-weight:bold">${item.accuracy}%</td>
775
+ <td style="color: ${statusColor}; font-weight:bold">${statusText}</td>
776
+ <td>${item.scan_date}</td>
777
+ </tr>
778
+ `;
779
+ }
780
+ });
781
+ if (data.history.length === 0) tbody.innerHTML = "<tr><td colspan='7' style='text-align:center;padding:30px;color:rgba(255,255,255,0.3)'>Belum ada history. Scan gambar atau jalankan batch test.</td></tr>";
782
+ } catch (err) {
783
+ tbody.innerHTML = "<tr><td colspan='7' style='text-align:center;padding:30px;color:var(--danger)'>Gagal memuat data.</td></tr>";
784
+ }
785
+ }
style.css ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; }
2
+ :root {
3
+ --blue-dark: #001f3f; --blue-main: #0052D4; --blue-light: #4D8BF5;
4
+ --yellow-dark: #DAA520; --yellow-main: #FFD700; --yellow-light: #FFFACD;
5
+ --white: #ffffff; --danger: #ff4757; --success: #2ed573;
6
+ }
7
+
8
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
9
+ ::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); }
10
+ ::-webkit-scrollbar-thumb { background: var(--yellow-main); border-radius: 4px; }
11
+ body { background-color: var(--blue-dark); background-image: radial-gradient(circle, rgba(255,215,0,0.08) 1.5px, transparent 1.5px); background-size: 30px 30px; color: var(--white); overflow-x: hidden; min-height: 100vh; }
12
+ body::before { content:''; position:fixed;top:0;left:0;width:100%;height:100%;background:linear-gradient(135deg,rgba(0,31,63,0.85),rgba(0,42,92,0.9));z-index:-1; }
13
+ body::-webkit-scrollbar { display: none; }
14
+
15
+ /* ADS */
16
+ .ads-container { display: none !important; }
17
+
18
+ /* LOADING */
19
+ .polka-dot-bg { position:fixed;top:0;left:0;width:100%;height:100%;background:var(--blue-main);display:flex;justify-content:center;align-items:center;z-index:1000; }
20
+ .loading-content { background:rgba(0,31,63,0.95);padding:40px 50px;border-radius:20px;text-align:center;border:2px solid var(--yellow-main);box-shadow:0 0 40px rgba(255,215,0,0.15); }
21
+ .logo-big { font-size:60px;margin-bottom:10px; }
22
+ .loader-bar { width:220px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;margin:20px auto;overflow:hidden; }
23
+ .loader-fill { width:0;height:100%;background:linear-gradient(90deg,var(--yellow-dark),var(--yellow-main));border-radius:3px;animation:load 2s ease-in-out forwards; }
24
+ @keyframes load { to { width:100% } }
25
+
26
+ /* AUTH - OLD UI */
27
+ #auth-page { z-index:1; }
28
+ .auth-float { position:fixed; z-index:-1; padding:10px 18px; border-radius:12px; background:rgba(255,215,0,0.06); border:1px solid rgba(255,215,0,0.1); font-size:13px; font-weight:600; color:rgba(255,215,0,0.5); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px); animation:authFloat 6s ease-in-out infinite; pointer-events:none; white-space:nowrap; }
29
+ @keyframes authFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-18px)} }
30
+ .hidden { display:none!important; }
31
+ #auth-page .auth-box { max-width:380px;margin:10% auto;background:#001a33;padding:30px;border-radius:12px;box-shadow:0 0 20px rgba(0,0,0,0.5);border:1px solid rgba(255,215,0,0.15); }
32
+ #auth-page .auth-box h2 { text-align:center;margin-bottom:20px;font-size:20px;color:var(--yellow-main); }
33
+ #auth-page .tabs { display:flex;margin-bottom:18px;background:rgba(0,0,0,0.3);border-radius:8px;padding:2px; }
34
+ #auth-page .tab-btn { flex:1;padding:10px;background:none;border:none;color:rgba(255,255,255,0.5);font-size:14px;cursor:pointer;border-radius:6px;transition:.3s;font-weight:600; }
35
+ #auth-page .tab-btn.active { background:var(--yellow-main);color:var(--blue-dark); }
36
+ #auth-page .auth-form { display:flex;flex-direction:column;gap:12px; }
37
+ #auth-page .auth-form input { padding:12px 14px;border-radius:8px;border:1px solid rgba(255,215,0,0.15);background:rgba(0,0,0,0.3);color:#fff;font-size:14px;outline:none;transition:.3s; }
38
+ #auth-page .auth-form input:focus { border-color:var(--yellow-main); }
39
+ #auth-page .auth-form input::placeholder { color:rgba(255,255,255,0.3); }
40
+ #auth-page .btn-primary { padding:12px;background:linear-gradient(135deg,var(--yellow-dark),var(--yellow-main));color:var(--blue-dark);font-weight:700;border:none;border-radius:8px;cursor:pointer;font-size:15px;transition:.3s; }
41
+ #auth-page .btn-primary:hover { opacity:.9; }
42
+ #auth-page .error-text { color:var(--danger);font-size:13px;text-align:center;min-height:20px; }
43
+
44
+ /* LAYOUT */
45
+ #main-app { display:flex;height:100vh;position:relative;z-index:1; }
46
+ .sidebar { width:240px;background:rgba(0,31,63,0.7);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);padding:25px 0;display:flex;flex-direction:column;border-right:1px solid rgba(255,215,0,0.15);flex-shrink:0;transition:all 0.3s ease; }
47
+ .logo-small { font-size:36px;text-align:center;margin-bottom:30px;filter:drop-shadow(0 2px 10px rgba(255,215,0,0.3)); }
48
+ .sidebar ul { list-style:none;flex:1; }
49
+ .nav-item { padding:16px 24px;cursor:pointer;transition:.3s;border-left:4px solid transparent;font-size:15px;display:flex;align-items:center;gap:12px;color:rgba(255,255,255,0.75);font-weight:500; }
50
+ .nav-item:hover { background:rgba(255,255,255,0.05);color:#fff; }
51
+ .nav-item.active { background:rgba(255,215,0,0.08);border-left-color:var(--yellow-main);color:var(--yellow-main);font-weight:700; }
52
+ .btn-logout { margin:15px 24px;padding:12px;background:rgba(255,71,87,0.12);color:var(--danger);border:1px solid rgba(255,71,87,0.25);border-radius:12px;cursor:pointer;font-weight:700;transition:.3s;font-size:14px;display:flex;align-items:center;justify-content:center;gap:8px; }
53
+ .btn-logout:hover { background:rgba(255,71,87,0.22);box-shadow:0 0 15px rgba(255,71,87,0.15); }
54
+
55
+ .content { flex:1;padding:30px 45px;overflow-y:auto;margin:0;min-height:100vh;transition:all 0.3s ease; }
56
+
57
+ /* RESPONSIVE NAVIGATION & BOTTOM BAR FOR TABLETS & PHONES (< 768px) */
58
+ @media (max-width: 768px) {
59
+ #main-app {
60
+ flex-direction: column;
61
+ }
62
+ .sidebar {
63
+ position: fixed;
64
+ bottom: 0;
65
+ left: 0;
66
+ width: 100vw;
67
+ height: 65px;
68
+ padding: 0;
69
+ flex-direction: row;
70
+ border-right: none;
71
+ border-top: 1px solid rgba(255,215,0,0.2);
72
+ background: rgba(0, 26, 51, 0.95);
73
+ box-shadow: 0 -8px 30px rgba(0,0,0,0.5);
74
+ z-index: 1000;
75
+ }
76
+ .logo-small {
77
+ display: none !important;
78
+ }
79
+ .sidebar ul {
80
+ display: flex;
81
+ flex-direction: row;
82
+ flex: 5;
83
+ height: 100%;
84
+ justify-content: space-around;
85
+ align-items: center;
86
+ }
87
+ .nav-item {
88
+ padding: 0;
89
+ height: 100%;
90
+ flex: 1;
91
+ flex-direction: column;
92
+ justify-content: center;
93
+ align-items: center;
94
+ border-left: none;
95
+ border-top: 3px solid transparent;
96
+ font-size: 18px;
97
+ gap: 3px;
98
+ color: rgba(255,255,255,0.6);
99
+ }
100
+ .nav-item span {
101
+ display: block !important;
102
+ font-size: 9px;
103
+ font-weight: 600;
104
+ color: rgba(255,255,255,0.6);
105
+ }
106
+ .nav-item:hover {
107
+ background: rgba(255,255,255,0.02);
108
+ }
109
+ .nav-item.active {
110
+ border-top-color: var(--yellow-main);
111
+ border-left-color: transparent;
112
+ color: var(--yellow-main);
113
+ background: rgba(255,215,0,0.06);
114
+ }
115
+ .nav-item.active span {
116
+ color: var(--yellow-main);
117
+ }
118
+ .btn-logout {
119
+ margin: 0;
120
+ height: 100%;
121
+ flex: 1;
122
+ background: none;
123
+ border: none;
124
+ border-top: 3px solid transparent;
125
+ border-radius: 0;
126
+ display: flex;
127
+ flex-direction: column;
128
+ justify-content: center;
129
+ align-items: center;
130
+ font-size: 18px;
131
+ gap: 3px;
132
+ padding: 0;
133
+ color: var(--danger);
134
+ }
135
+ .btn-logout span {
136
+ display: block !important;
137
+ font-size: 9px;
138
+ font-weight: 600;
139
+ color: var(--danger);
140
+ }
141
+ .btn-logout:hover {
142
+ background: rgba(255, 71, 87, 0.06);
143
+ }
144
+ .content {
145
+ padding: 20px 20px 85px 20px;
146
+ min-height: calc(100vh - 65px);
147
+ }
148
+ }
149
+
150
+ header { margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.06); }
151
+ header h2 { font-size:20px;font-weight:600; }
152
+ header h2 span { color:var(--yellow-main); }
153
+
154
+ /* SECTIONS */
155
+ .section { display:none; }
156
+ .section.active { display:block;animation:fadeIn .4s ease; }
157
+ @keyframes fadeIn { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
158
+
159
+ /* STATS */
160
+ .stats-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:24px; }
161
+ .stat-card { padding:20px;border-radius:12px;text-align:center;color:var(--blue-dark);transition:.3s; }
162
+ .stat-card:hover { transform:translateY(-3px);box-shadow:0 8px 25px rgba(0,0,0,0.2); }
163
+ .stat-card.blue { background:linear-gradient(135deg,#4D8BF5,#3a6fd8); }
164
+ .stat-card.yellow { background:linear-gradient(135deg,#FFD700,#f0c800); }
165
+ .stat-card h3 { font-size:28px;margin-bottom:4px;font-weight:800; }
166
+ .stat-card p { font-size:12px;font-weight:600;opacity:.8;text-transform:uppercase;letter-spacing:.5px; }
167
+ .info-box { background:rgba(255,255,255,0.05);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);padding:18px 20px;border-radius:12px;border-left:3px solid var(--yellow-main);margin-bottom:16px; }
168
+ .info-box h3 { margin-bottom:10px;font-size:15px; }
169
+ .info-box p { font-size:13px;line-height:1.6;color:rgba(255,255,255,0.8); }
170
+ .info-box ol { margin-left:18px;font-size:13px;line-height:1.8;color:rgba(255,255,255,0.8); }
171
+
172
+ /* UPLOAD */
173
+ .upload-container { display:flex;flex-direction:column;align-items:center;gap:16px; }
174
+ .upload-box { width:100%;max-width:480px;min-height:200px;border:2px dashed rgba(255,215,0,0.3);border-radius:14px;display:flex;flex-direction:column;justify-content:center;align-items:center;cursor:pointer;transition:.3s;background:rgba(255,255,255,0.02);padding:30px; }
175
+ .upload-box:hover { background:rgba(255,215,0,0.04);border-color:var(--yellow-main); }
176
+ .upload-icon { font-size:44px;margin-bottom:10px;opacity:.7; }
177
+ .upload-box p { font-size:14px;color:rgba(255,255,255,0.6); }
178
+ .upload-box small { font-size:12px;color:var(--yellow-main);margin-top:6px; }
179
+ .btn-scan { padding:14px 50px;font-size:16px;background:linear-gradient(135deg,var(--yellow-dark),var(--yellow-main));color:var(--blue-dark);border:none;border-radius:50px;font-weight:700;cursor:pointer;box-shadow:0 4px 15px rgba(255,215,0,0.25);transition:.3s; }
180
+ .btn-scan:hover { transform:translateY(-2px);box-shadow:0 8px 25px rgba(255,215,0,0.35); }
181
+ .btn-scan:active { transform:scale(.96); }
182
+ .btn-scan:disabled { opacity:.5;cursor:not-allowed;transform:none; }
183
+
184
+ /* RESULTS */
185
+ .result-box { margin-top:24px;padding:24px;border-radius:14px;border:2px solid;animation:slideIn .4s ease; }
186
+ @keyframes slideIn { from{transform:translateX(-30px);opacity:0} to{transform:translateX(0);opacity:1} }
187
+ .result-box.ai { background:rgba(255,71,87,0.06);border-color:rgba(255,71,87,0.4); }
188
+ .result-box.real { background:rgba(46,213,115,0.06);border-color:rgba(46,213,115,0.4); }
189
+ .result-title { font-size:20px;margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px; }
190
+ .details-grid { display:grid;grid-template-columns:1fr 1fr;gap:12px; }
191
+ @media (max-width:500px) { .details-grid { grid-template-columns:1fr; } }
192
+ .detail-item { background:rgba(0,0,0,0.15);padding:10px 14px;border-radius:8px; }
193
+ .detail-label { font-size:11px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:.3px; }
194
+ .detail-value { font-size:14px;font-weight:600;color:var(--yellow-main);margin-top:4px;word-break:break-all; }
195
+
196
+ /* TABLE */
197
+ .table-wrap { overflow-x:auto;max-height:55vh;border-radius:12px;border:1px solid rgba(255,255,255,0.06);margin-top:12px; }
198
+ .history-table { width:100%;border-collapse:collapse;background:rgba(255,255,255,0.02);min-width:680px; }
199
+ .history-table th { position:sticky;top:0;z-index:2;padding:12px;text-align:left;background:rgba(0,82,212,0.5);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:var(--yellow-main);font-size:12px;text-transform:uppercase;letter-spacing:.4px;font-weight:600; }
200
+ .history-table td { padding:11px 12px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px; }
201
+ .history-table tbody tr { transition:.2s; }
202
+ .history-table tbody tr:hover { background:rgba(255,255,255,0.05); }
203
+ .btn-refresh { padding:9px 18px;background:linear-gradient(135deg,var(--yellow-dark),var(--yellow-main));color:var(--blue-dark);border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:13px;transition:.3s; }
204
+ .btn-refresh:hover { transform:translateY(-1px);box-shadow:0 4px 12px rgba(255,215,0,0.25); }
205
+
206
+ /* PARTICLES */
207
+ .click-particle { position:fixed;pointer-events:none;border-radius:50%;z-index:9999;animation:particleFly .8s ease-out forwards; }
208
+ @keyframes particleFly { to { transform:translate(var(--tx),var(--ty)) scale(0);opacity:0 } }
209
+
210
+ canvas { max-height:240px;width:100%!important; }
211
+ .chart-grid { display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px; }
212
+ @media (max-width:700px) { .chart-grid { grid-template-columns:1fr; } }
213
+
214
+ /* BATCH FILE LIST */
215
+ #batch-file-list table { margin-top:8px; }