vicliv commited on
Commit
b08bc0e
·
1 Parent(s): 0e2f6dd

Add disclaimer section and error report form with HF upload

Browse files
Files changed (3) hide show
  1. app/main.py +80 -1
  2. app/requirements.txt +1 -0
  3. app/static/index.html +279 -5
app/main.py CHANGED
@@ -1,9 +1,13 @@
1
  import io
 
 
2
  import random
3
  import tempfile
 
 
4
  from pathlib import Path
5
 
6
- from fastapi import FastAPI, File, HTTPException, UploadFile
7
  from fastapi.staticfiles import StaticFiles
8
  from PIL import Image, ImageOps
9
 
@@ -18,6 +22,9 @@ N_VIDEO_FRAMES = 5
18
  IMAGE_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
19
  VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"}
20
 
 
 
 
21
  app = FastAPI(title="Deepfake Detector")
22
 
23
 
@@ -123,5 +130,77 @@ async def predict(file: UploadFile = File(...)):
123
  raise HTTPException(415, f"Unsupported media type: {content_type}")
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  static_dir = Path(__file__).parent / "static"
127
  app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
 
 
1
  import io
2
+ import json
3
+ import os
4
  import random
5
  import tempfile
6
+ import uuid
7
+ from datetime import datetime, timezone
8
  from pathlib import Path
9
 
10
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
11
  from fastapi.staticfiles import StaticFiles
12
  from PIL import Image, ImageOps
13
 
 
22
  IMAGE_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
23
  VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"}
24
 
25
+ HF_REPORT_REPO = os.environ.get("HF_REPORT_REPO", "ComplexDataLab/openfake-reports")
26
+ HF_TOKEN = os.environ.get("HF_TOKEN")
27
+
28
  app = FastAPI(title="Deepfake Detector")
29
 
30
 
 
130
  raise HTTPException(415, f"Unsupported media type: {content_type}")
131
 
132
 
133
+ @app.post("/api/report")
134
+ async def report(
135
+ file: UploadFile = File(...),
136
+ is_real: str = Form(...),
137
+ reason: str = Form(...),
138
+ reason_other: str = Form(""),
139
+ comment: str = Form(""),
140
+ p_fake: float = Form(...),
141
+ consent: str = Form(...),
142
+ ):
143
+ """Save an error report (form answers + image) to a Hugging Face dataset repo."""
144
+ if consent != "true":
145
+ raise HTTPException(400, "Image saving consent is required.")
146
+
147
+ if not HF_TOKEN:
148
+ raise HTTPException(
149
+ 503, "Reporting is not configured (missing HF_TOKEN)."
150
+ )
151
+
152
+ # Read the uploaded image
153
+ raw = await file.read()
154
+ content_type = (file.content_type or "").lower()
155
+ if content_type not in IMAGE_TYPES:
156
+ raise HTTPException(415, "Only images can be reported.")
157
+
158
+ # Build report payload
159
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
160
+ short_id = uuid.uuid4().hex[:8]
161
+ folder_name = f"{ts}_{short_id}"
162
+
163
+ report_data = {
164
+ "timestamp": datetime.now(timezone.utc).isoformat(),
165
+ "is_real": is_real,
166
+ "reason": reason,
167
+ "reason_other": reason_other if reason == "other" else "",
168
+ "comment": comment,
169
+ "p_fake": p_fake,
170
+ "original_filename": file.filename or "unknown",
171
+ }
172
+
173
+ # Write to a temp directory then upload to HF
174
+ with tempfile.TemporaryDirectory() as tmpdir:
175
+ report_dir = Path(tmpdir) / folder_name
176
+ report_dir.mkdir()
177
+
178
+ # Save report JSON
179
+ (report_dir / "report.json").write_text(
180
+ json.dumps(report_data, indent=2, ensure_ascii=False)
181
+ )
182
+
183
+ # Save image with original extension
184
+ ext = Path(file.filename or "image.jpg").suffix or ".jpg"
185
+ (report_dir / f"image{ext}").write_bytes(raw)
186
+
187
+ # Upload to HF dataset repo
188
+ try:
189
+ from huggingface_hub import HfApi
190
+
191
+ api = HfApi(token=HF_TOKEN)
192
+ api.upload_folder(
193
+ folder_path=str(report_dir),
194
+ path_in_repo=f"reports/{folder_name}",
195
+ repo_id=HF_REPORT_REPO,
196
+ repo_type="dataset",
197
+ )
198
+ except Exception as e:
199
+ raise HTTPException(500, f"Failed to upload report: {e}")
200
+
201
+ return {"status": "ok"}
202
+
203
+
204
  static_dir = Path(__file__).parent / "static"
205
  app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
206
+
app/requirements.txt CHANGED
@@ -6,3 +6,4 @@ Pillow==10.4.0
6
  opencv-python-headless==4.10.0.84
7
  python-multipart==0.0.12
8
  numpy==1.26.4
 
 
6
  opencv-python-headless==4.10.0.84
7
  python-multipart==0.0.12
8
  numpy==1.26.4
9
+ huggingface_hub>=0.23.0
app/static/index.html CHANGED
@@ -127,22 +127,46 @@
127
  </div>
128
  </div>
129
 
130
- <div class="mt-8 flex justify-center">
131
  <button id="analyze-another-btn"
132
  class="px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
133
  <span data-i18n="analyze_another">Analyze another file</span>
134
  </button>
 
 
 
 
 
 
 
135
  </div>
136
  </div>
137
  </section>
138
 
139
- <footer class="mt-16 text-center text-xs text-gray-400">
140
- <span data-i18n="privacy_note">Files are processed in memory and not stored.</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  </footer>
142
  </main>
143
  </div>
144
 
145
- <!-- Modal -->
146
  <div id="modal-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
147
  <div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
148
  <div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
@@ -156,6 +180,88 @@
156
  </div>
157
  </div>
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  <script>
160
  const I18N = {
161
  en: {
@@ -192,6 +298,32 @@
192
  how_calculated_body: "We use a Swin Transformer V2 model fine-tuned to distinguish real photographs from AI-generated images. For videos, we sample 5 frames evenly across the duration and average the model's confidence. The score shown is the model's estimated probability that the content was generated by AI.",
193
  close: "Close",
194
  privacy_note: "Files are processed in memory and not stored.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  },
196
  fr: {
197
  title: "Détecteur d'hypertrucage",
@@ -227,6 +359,32 @@
227
  how_calculated_body: "Nous utilisons un modèle Swin Transformer V2 entraîné pour distinguer les vraies photographies des images générées par IA. Pour les vidéos, nous échantillonnons 5 images réparties uniformément sur la durée et faisons la moyenne de la confiance du modèle. Le score affiché correspond à la probabilité estimée que le contenu ait été généré par IA.",
228
  close: "Fermer",
229
  privacy_note: "Les fichiers sont traités en mémoire et ne sont pas conservés.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  },
231
  };
232
 
@@ -252,6 +410,10 @@
252
  const key = el.getAttribute("data-i18n");
253
  if (t()[key] != null) el.textContent = t()[key];
254
  });
 
 
 
 
255
  $("lang-en").className = "px-3 py-1 rounded-full font-semibold " +
256
  (state.lang === "en" ? "bg-blue-600 text-white" : "text-gray-600");
257
  $("lang-fr").className = "px-3 py-1 rounded-full font-semibold " +
@@ -492,6 +654,7 @@
492
  }
493
  }
494
 
 
495
  function openModal() {
496
  $("modal").classList.remove("hidden");
497
  $("modal").classList.add("flex");
@@ -503,6 +666,93 @@
503
  $("modal-backdrop").classList.add("hidden");
504
  }
505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  function init() {
507
  applyI18n();
508
 
@@ -547,11 +797,35 @@
547
  showCard("upload-card");
548
  });
549
 
 
550
  $("how-link").addEventListener("click", openModal);
551
  $("modal-close").addEventListener("click", closeModal);
552
  $("modal-backdrop").addEventListener("click", closeModal);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  document.addEventListener("keydown", (e) => {
554
- if (e.key === "Escape") closeModal();
 
 
 
555
  });
556
  }
557
 
 
127
  </div>
128
  </div>
129
 
130
+ <div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
131
  <button id="analyze-another-btn"
132
  class="px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
133
  <span data-i18n="analyze_another">Analyze another file</span>
134
  </button>
135
+ <button id="report-btn"
136
+ class="px-6 py-3 rounded-xl border border-amber-300 bg-amber-50 text-amber-800 font-semibold hover:bg-amber-100 transition-colors flex items-center gap-2 justify-center">
137
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
138
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
139
+ </svg>
140
+ <span data-i18n="report_error">Signal an error</span>
141
+ </button>
142
  </div>
143
  </div>
144
  </section>
145
 
146
+ <!-- Disclaimer -->
147
+ <footer class="mt-16 max-w-2xl mx-auto">
148
+ <div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
149
+ <div class="flex items-start gap-3">
150
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-400 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
151
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
152
+ </svg>
153
+ <div class="text-sm text-gray-500 space-y-2">
154
+ <p data-i18n="privacy_note">Files are processed in memory and not stored.</p>
155
+ <p class="font-medium text-gray-600" data-i18n="disclaimer_mistakes">This model can make mistakes.</p>
156
+ <p class="font-medium text-gray-600" data-i18n="disclaimer_known_issues_title">Known limitations:</p>
157
+ <ul class="list-disc list-inside space-y-1 text-gray-500">
158
+ <li data-i18n="disclaimer_lipsync">Lip sync and small inpainting may not be reliably detected.</li>
159
+ <li data-i18n="disclaimer_text_overlay">Images with text overlay are often incorrectly flagged as AI-generated.</li>
160
+ <li data-i18n="disclaimer_full_gen">The model is very good at detecting fully AI-generated images.</li>
161
+ </ul>
162
+ </div>
163
+ </div>
164
+ </div>
165
  </footer>
166
  </main>
167
  </div>
168
 
169
+ <!-- How-it-works Modal -->
170
  <div id="modal-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
171
  <div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
172
  <div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
 
180
  </div>
181
  </div>
182
 
183
+ <!-- Report Error Modal -->
184
+ <div id="report-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
185
+ <div id="report-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
186
+ <div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl my-8">
187
+ <h2 class="text-xl font-bold" data-i18n="report_title">Signal an error</h2>
188
+ <p class="mt-2 text-sm text-gray-500" data-i18n="report_description">Help us improve by reporting an incorrect result.</p>
189
+
190
+ <form id="report-form" class="mt-6 space-y-6">
191
+ <!-- Is the image real or AI? -->
192
+ <fieldset>
193
+ <legend class="text-sm font-semibold text-gray-900" data-i18n="report_is_real_label">Is this image real or AI-generated?</legend>
194
+ <div class="mt-3 space-y-2">
195
+ <label class="flex items-center gap-2 cursor-pointer">
196
+ <input type="radio" name="is_real" value="real" class="w-4 h-4 text-blue-600" required />
197
+ <span data-i18n="report_real">Real (authentic)</span>
198
+ </label>
199
+ <label class="flex items-center gap-2 cursor-pointer">
200
+ <input type="radio" name="is_real" value="ai" class="w-4 h-4 text-blue-600" />
201
+ <span data-i18n="report_ai">AI-generated or manipulated</span>
202
+ </label>
203
+ </div>
204
+ </fieldset>
205
+
206
+ <!-- Reason -->
207
+ <fieldset>
208
+ <legend class="text-sm font-semibold text-gray-900" data-i18n="report_reason_label">How do you know?</legend>
209
+ <div class="mt-3 space-y-2">
210
+ <label class="flex items-center gap-2 cursor-pointer">
211
+ <input type="radio" name="reason" value="self_made" class="w-4 h-4 text-blue-600" required />
212
+ <span data-i18n="report_reason_self">I generated or took this image myself</span>
213
+ </label>
214
+ <label class="flex items-center gap-2 cursor-pointer">
215
+ <input type="radio" name="reason" value="evidence" class="w-4 h-4 text-blue-600" />
216
+ <span data-i18n="report_reason_evidence">Clear evidence of manipulation</span>
217
+ </label>
218
+ <label class="flex items-center gap-2 cursor-pointer">
219
+ <input type="radio" name="reason" value="known_source" class="w-4 h-4 text-blue-600" />
220
+ <span data-i18n="report_reason_source">Known source of the image</span>
221
+ </label>
222
+ <label class="flex items-center gap-2 cursor-pointer">
223
+ <input type="radio" name="reason" value="other" class="w-4 h-4 text-blue-600" />
224
+ <span data-i18n="report_reason_other">Other</span>
225
+ </label>
226
+ </div>
227
+ <div id="reason-other-wrap" class="hidden mt-3">
228
+ <input id="reason-other-input" type="text" placeholder="" data-i18n-placeholder="report_reason_other_placeholder"
229
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
230
+ </div>
231
+ </fieldset>
232
+
233
+ <!-- Comment -->
234
+ <div>
235
+ <label class="text-sm font-semibold text-gray-900" data-i18n="report_comment_label">Additional comments (optional)</label>
236
+ <textarea id="report-comment" rows="3"
237
+ class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none"></textarea>
238
+ </div>
239
+
240
+ <!-- Consent -->
241
+ <label class="flex items-start gap-3 cursor-pointer">
242
+ <input id="report-consent" type="checkbox" class="w-4 h-4 mt-0.5 text-blue-600 rounded" />
243
+ <span class="text-sm text-gray-700" data-i18n="report_consent">I allow saving this image for research purposes. This is required to submit the report.</span>
244
+ </label>
245
+
246
+ <!-- Error / success banners -->
247
+ <div id="report-error" class="hidden rounded-lg bg-red-50 border border-red-200 text-red-700 px-4 py-3 text-sm"></div>
248
+ <div id="report-success" class="hidden rounded-lg bg-green-50 border border-green-200 text-green-700 px-4 py-3 text-sm" data-i18n="report_success">Thank you! Your report has been submitted.</div>
249
+
250
+ <!-- Buttons -->
251
+ <div class="flex gap-3 justify-end">
252
+ <button type="button" id="report-cancel"
253
+ class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
254
+ <span data-i18n="report_cancel">Cancel</span>
255
+ </button>
256
+ <button type="submit" id="report-submit"
257
+ class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
258
+ <span data-i18n="report_submit">Submit report</span>
259
+ </button>
260
+ </div>
261
+ </form>
262
+ </div>
263
+ </div>
264
+
265
  <script>
266
  const I18N = {
267
  en: {
 
298
  how_calculated_body: "We use a Swin Transformer V2 model fine-tuned to distinguish real photographs from AI-generated images. For videos, we sample 5 frames evenly across the duration and average the model's confidence. The score shown is the model's estimated probability that the content was generated by AI.",
299
  close: "Close",
300
  privacy_note: "Files are processed in memory and not stored.",
301
+ disclaimer_mistakes: "This model can make mistakes.",
302
+ disclaimer_known_issues_title: "Known limitations:",
303
+ disclaimer_lipsync: "Lip sync and small inpainting may not be reliably detected.",
304
+ disclaimer_text_overlay: "Images with text overlay are often incorrectly flagged as AI-generated.",
305
+ disclaimer_full_gen: "The model is very good at detecting fully AI-generated images.",
306
+ report_error: "Signal an error",
307
+ report_title: "Signal an error",
308
+ report_description: "Help us improve by reporting an incorrect result.",
309
+ report_is_real_label: "Is this image real or AI-generated?",
310
+ report_real: "Real (authentic)",
311
+ report_ai: "AI-generated or manipulated",
312
+ report_reason_label: "How do you know?",
313
+ report_reason_self: "I generated or took this image myself",
314
+ report_reason_evidence: "Clear evidence of manipulation",
315
+ report_reason_source: "Known source of the image",
316
+ report_reason_other: "Other",
317
+ report_reason_other_placeholder: "Please specify…",
318
+ report_comment_label: "Additional comments (optional)",
319
+ report_consent: "I allow saving this image for research purposes. This is required to submit the report.",
320
+ report_cancel: "Cancel",
321
+ report_submit: "Submit report",
322
+ report_submitting: "Submitting...",
323
+ report_success: "Thank you! Your report has been submitted.",
324
+ report_error_consent: "You must allow saving the image to submit a report.",
325
+ report_error_fields: "Please fill in all required fields.",
326
+ report_error_generic: "Failed to submit the report. Please try again.",
327
  },
328
  fr: {
329
  title: "Détecteur d'hypertrucage",
 
359
  how_calculated_body: "Nous utilisons un modèle Swin Transformer V2 entraîné pour distinguer les vraies photographies des images générées par IA. Pour les vidéos, nous échantillonnons 5 images réparties uniformément sur la durée et faisons la moyenne de la confiance du modèle. Le score affiché correspond à la probabilité estimée que le contenu ait été généré par IA.",
360
  close: "Fermer",
361
  privacy_note: "Les fichiers sont traités en mémoire et ne sont pas conservés.",
362
+ disclaimer_mistakes: "Ce modèle peut faire des erreurs.",
363
+ disclaimer_known_issues_title: "Limitations connues :",
364
+ disclaimer_lipsync: "La synchronisation labiale et les petits inpaintings peuvent ne pas être détectés de manière fiable.",
365
+ disclaimer_text_overlay: "Les images avec du texte superposé sont souvent incorrectement signalées comme générées par IA.",
366
+ disclaimer_full_gen: "Le modèle est très performant pour détecter les images entièrement générées par IA.",
367
+ report_error: "Signaler une erreur",
368
+ report_title: "Signaler une erreur",
369
+ report_description: "Aidez-nous à nous améliorer en signalant un résultat incorrect.",
370
+ report_is_real_label: "Cette image est-elle réelle ou générée par IA ?",
371
+ report_real: "Réelle (authentique)",
372
+ report_ai: "Générée par IA ou manipulée",
373
+ report_reason_label: "Comment le savez-vous ?",
374
+ report_reason_self: "J'ai généré ou pris cette image moi-même",
375
+ report_reason_evidence: "Preuve claire de manipulation",
376
+ report_reason_source: "Source connue de l'image",
377
+ report_reason_other: "Autre",
378
+ report_reason_other_placeholder: "Veuillez préciser…",
379
+ report_comment_label: "Commentaires supplémentaires (optionnel)",
380
+ report_consent: "J'autorise la sauvegarde de cette image à des fins de recherche. Ceci est requis pour soumettre le signalement.",
381
+ report_cancel: "Annuler",
382
+ report_submit: "Envoyer le signalement",
383
+ report_submitting: "Envoi en cours...",
384
+ report_success: "Merci ! Votre signalement a été soumis.",
385
+ report_error_consent: "Vous devez autoriser la sauvegarde de l'image pour soumettre un signalement.",
386
+ report_error_fields: "Veuillez remplir tous les champs obligatoires.",
387
+ report_error_generic: "Échec de l'envoi du signalement. Veuillez réessayer.",
388
  },
389
  };
390
 
 
410
  const key = el.getAttribute("data-i18n");
411
  if (t()[key] != null) el.textContent = t()[key];
412
  });
413
+ document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
414
+ const key = el.getAttribute("data-i18n-placeholder");
415
+ if (t()[key] != null) el.placeholder = t()[key];
416
+ });
417
  $("lang-en").className = "px-3 py-1 rounded-full font-semibold " +
418
  (state.lang === "en" ? "bg-blue-600 text-white" : "text-gray-600");
419
  $("lang-fr").className = "px-3 py-1 rounded-full font-semibold " +
 
654
  }
655
  }
656
 
657
+ /* --- How-it-works modal --- */
658
  function openModal() {
659
  $("modal").classList.remove("hidden");
660
  $("modal").classList.add("flex");
 
666
  $("modal-backdrop").classList.add("hidden");
667
  }
668
 
669
+ /* --- Report modal --- */
670
+ function openReportModal() {
671
+ // Reset the form
672
+ $("report-form").reset();
673
+ $("reason-other-wrap").classList.add("hidden");
674
+ $("report-error").classList.add("hidden");
675
+ $("report-success").classList.add("hidden");
676
+ $("report-submit").disabled = false;
677
+ $("report-submit").querySelector("[data-i18n]").textContent = t().report_submit;
678
+ // Show modal
679
+ $("report-modal").classList.remove("hidden");
680
+ $("report-modal").classList.add("flex");
681
+ $("report-backdrop").classList.remove("hidden");
682
+ }
683
+
684
+ function closeReportModal() {
685
+ $("report-modal").classList.add("hidden");
686
+ $("report-modal").classList.remove("flex");
687
+ $("report-backdrop").classList.add("hidden");
688
+ }
689
+
690
+ async function submitReport(e) {
691
+ e.preventDefault();
692
+ const T = t();
693
+ const errorEl = $("report-error");
694
+ const successEl = $("report-success");
695
+ errorEl.classList.add("hidden");
696
+ successEl.classList.add("hidden");
697
+
698
+ // Validate consent
699
+ if (!$("report-consent").checked) {
700
+ errorEl.textContent = T.report_error_consent;
701
+ errorEl.classList.remove("hidden");
702
+ return;
703
+ }
704
+
705
+ // Gather radio values
706
+ const isRealRadio = document.querySelector('input[name="is_real"]:checked');
707
+ const reasonRadio = document.querySelector('input[name="reason"]:checked');
708
+ if (!isRealRadio || !reasonRadio) {
709
+ errorEl.textContent = T.report_error_fields;
710
+ errorEl.classList.remove("hidden");
711
+ return;
712
+ }
713
+
714
+ // If reason is "other", require the text input
715
+ const reasonValue = reasonRadio.value;
716
+ const reasonOther = $("reason-other-input").value.trim();
717
+ if (reasonValue === "other" && !reasonOther) {
718
+ errorEl.textContent = T.report_error_fields;
719
+ errorEl.classList.remove("hidden");
720
+ return;
721
+ }
722
+
723
+ // Build form data
724
+ const formData = new FormData();
725
+ formData.append("file", state.file);
726
+ formData.append("is_real", isRealRadio.value);
727
+ formData.append("reason", reasonValue);
728
+ formData.append("reason_other", reasonOther);
729
+ formData.append("comment", $("report-comment").value.trim());
730
+ formData.append("p_fake", state.result ? state.result.p_fake : 0);
731
+ formData.append("consent", "true");
732
+
733
+ // Disable button and show loading
734
+ const submitBtn = $("report-submit");
735
+ submitBtn.disabled = true;
736
+ submitBtn.querySelector("[data-i18n]").textContent = T.report_submitting;
737
+
738
+ try {
739
+ const res = await fetch("/api/report", { method: "POST", body: formData });
740
+ if (!res.ok) {
741
+ const body = await res.json().catch(() => ({}));
742
+ throw new Error(body.detail || T.report_error_generic);
743
+ }
744
+ successEl.textContent = T.report_success;
745
+ successEl.classList.remove("hidden");
746
+ // Auto-close after 2s
747
+ setTimeout(() => closeReportModal(), 2000);
748
+ } catch (err) {
749
+ errorEl.textContent = err.message || T.report_error_generic;
750
+ errorEl.classList.remove("hidden");
751
+ submitBtn.disabled = false;
752
+ submitBtn.querySelector("[data-i18n]").textContent = T.report_submit;
753
+ }
754
+ }
755
+
756
  function init() {
757
  applyI18n();
758
 
 
797
  showCard("upload-card");
798
  });
799
 
800
+ // How-it-works modal
801
  $("how-link").addEventListener("click", openModal);
802
  $("modal-close").addEventListener("click", closeModal);
803
  $("modal-backdrop").addEventListener("click", closeModal);
804
+
805
+ // Report modal
806
+ $("report-btn").addEventListener("click", openReportModal);
807
+ $("report-cancel").addEventListener("click", closeReportModal);
808
+ $("report-backdrop").addEventListener("click", closeReportModal);
809
+ $("report-form").addEventListener("submit", submitReport);
810
+
811
+ // Toggle "other" reason text input
812
+ document.querySelectorAll('input[name="reason"]').forEach((radio) => {
813
+ radio.addEventListener("change", () => {
814
+ const wrap = $("reason-other-wrap");
815
+ if (radio.value === "other" && radio.checked) {
816
+ wrap.classList.remove("hidden");
817
+ } else {
818
+ wrap.classList.add("hidden");
819
+ }
820
+ });
821
+ });
822
+
823
+ // Escape key closes any open modal
824
  document.addEventListener("keydown", (e) => {
825
+ if (e.key === "Escape") {
826
+ closeModal();
827
+ closeReportModal();
828
+ }
829
  });
830
  }
831