Harshit Ghosh commited on
Commit
e4fd6e0
Β·
1 Parent(s): 65e6c8d

feat: implement robust security middleware, authentication system, and frontend UI components

Browse files
.env.example CHANGED
@@ -1,24 +1,49 @@
1
- # Flask app
2
- ICH_APP_DEBUG=1
 
 
 
 
 
 
 
 
 
3
  ICH_APP_PORT=7860
4
- ICH_SECRET_KEY=change-me-in-production
5
 
6
- # Upload limits (MB)
 
 
 
 
 
 
 
 
 
 
 
 
7
  ICH_MAX_UPLOAD_MB=2048
 
8
 
9
- # Runtime model selection
10
- # Values: ensemble | best | 0 | 1 | 2 | 3 | 4
 
 
11
  ICH_FOLD_SELECTION=ensemble
12
 
13
- # Local mode enables server-side directory scan route
14
- ICH_LOCAL_MODE=1
 
15
 
16
- # Logging level
17
- # Values: DEBUG | INFO | WARNING | ERROR
 
18
  ICH_LOG_LEVEL=INFO
 
19
 
20
- # Hugging Face publishing
21
- # Create token: https://huggingface.co/settings/tokens
22
- HF_TOKEN=
23
- # Example: your-username/ich-b4-model
24
- HF_REPO_ID=
 
1
+ # ICH Screening Application - Environment Configuration
2
+ # STEP 1: Copy this file to .env
3
+ # STEP 2: Update values below with your configuration
4
+ # STEP 3: DO NOT commit .env to version control!
5
+
6
+ # ════════════════════════════════════════════════════════════════════════════
7
+ # APPLICATION & DEBUG
8
+ # ════════════════════════════════════════════════════════════════════════════
9
+ FLASK_ENV=production
10
+ FLASK_DEBUG=False
11
+ ICH_APP_DEBUG=False
12
  ICH_APP_PORT=7860
 
13
 
14
+ # Secret key for Flask sessions - MUST be set in production
15
+ # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
16
+ SECRET_KEY=CHANGE_ME_IN_PRODUCTION_USE_COMMAND_ABOVE
17
+
18
+ # ════════════════════════════════════════════════════════════════════════════
19
+ # DATABASE - NEON POSTGRESQL (REQUIRED)
20
+ # ════════════════════════════════════════════════════════════════════════════
21
+ # Your connection string from Neon
22
+ DATABASE_URL=postgresql://neondb_owner:npg_0hvkoPnReMz9@ep-rough-feather-aojsbj8l-pooler.c-2.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
23
+
24
+ # ════════════════════════════════════════════════════════════════════════════
25
+ # FILE UPLOADS & STORAGE
26
+ # ════════════════════════════════════════════════════════════════════════════
27
  ICH_MAX_UPLOAD_MB=2048
28
+ UPLOAD_BASE_DIR=uploads
29
 
30
+ # ════════════════════════════════════════════════════════════════════════════
31
+ # MODEL CONFIGURATION
32
+ # ════════════════════════════════════════════════════════════════════════════
33
+ # Fold selection: ensemble | best | 0 | 1 | 2 | 3 | 4
34
  ICH_FOLD_SELECTION=ensemble
35
 
36
+ # Hugging Face model repository
37
+ ICH_HF_MODEL_REPO=HarshCode/eff_b4_brain
38
+ ICH_HF_TOKEN=
39
 
40
+ # ════════════════════════════════════════════════════════════════════════════
41
+ # LOGGING & MONITORING
42
+ # ════════════════════════════════════════════════════════════════════════════
43
  ICH_LOG_LEVEL=INFO
44
+ ICH_LOCAL_MODE=True
45
 
46
+ # ════════════════════════════════════════════════════════════════════════════
47
+ # RENDER.COM DEPLOYMENT (optional)
48
+ # ════════════════════════════════════════════════════════════════════════════
49
+ # RENDER_EXTERNAL_HOSTNAME is set automatically by Render
 
.gitignore CHANGED
@@ -63,3 +63,5 @@ datasets/
63
  Thumbs.db
64
  .vscode/
65
  .idea/
 
 
 
63
  Thumbs.db
64
  .vscode/
65
  .idea/
66
+
67
+ doc_tem/
app_new.py ADDED
@@ -0,0 +1,972 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ICH Screening Web Application with User Authentication & Data Privacy
3
+ ======================================================================
4
+ Features:
5
+ 1. User authentication (login/register)
6
+ 2. User-specific data storage and privacy
7
+ 3. Upload .dcm files -> run AI model -> display screening report
8
+ 4. Browse past screening reports (user's data only)
9
+ 5. View execution logs (user's logs only)
10
+ 6. Production-ready security
11
+
12
+ Run:
13
+ python app.py (gunicorn in production)
14
+ Open http://127.0.0.1:7860
15
+ """
16
+
17
+ # pyright: reportCallIssue=false, reportArgumentType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportMissingParameterType=false, reportAttributeAccessIssue=false, reportMissingTypeStubs=false, reportDeprecated=false
18
+
19
+ from __future__ import annotations
20
+ import run_interface as ri
21
+ import datetime
22
+ import json
23
+ import logging
24
+ import os
25
+ import shutil
26
+ import sys
27
+ import tempfile
28
+ import threading
29
+ import time
30
+ import uuid
31
+ import zipfile
32
+ import math
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ try:
38
+ from dotenv import load_dotenv
39
+ except Exception:
40
+ load_dotenv = None
41
+
42
+ if load_dotenv:
43
+ load_dotenv()
44
+
45
+ hf_hub_download: Any = None
46
+ try:
47
+ import huggingface_hub
48
+ hf_hub_download = getattr(huggingface_hub, "hf_hub_download", None)
49
+ except Exception:
50
+ hf_hub_download = None
51
+
52
+ try:
53
+ import blackbox_recorder as bbr
54
+ except Exception:
55
+ class _NoopRecorder:
56
+ def configure(self, **_kwargs: Any) -> None:
57
+ return None
58
+ def start(self) -> None:
59
+ return None
60
+ def stop(self) -> None:
61
+ return None
62
+ def save_report(self, _path: str) -> None:
63
+ return None
64
+ def save_json(self, _path: str) -> None:
65
+ return None
66
+ bbr = _NoopRecorder()
67
+
68
+ from flask import (
69
+ Flask, abort, flash, g, jsonify, redirect, render_template, request,
70
+ send_from_directory, url_for
71
+ )
72
+ from werkzeug.utils import secure_filename
73
+ from flask_login import current_user, login_required
74
+
75
+ # Import new security and auth modules
76
+ from models import db, User, ScreeningReport
77
+ from auth_utils import init_auth, log_audit, get_client_ip
78
+ from auth_routes import auth_bp
79
+ from data_isolation import UserDataManager
80
+ from security import (
81
+ init_security, sanitize_filename, check_upload_rate_limit
82
+ )
83
+
84
+ # ══════════════════════════════════════════════════════════════════════════
85
+ # PATH CONFIGURATION
86
+ # ══════════════════════════════════════════════════════════════════════════
87
+
88
+ BASE_DIR = Path(__file__).resolve().parent
89
+ MODEL_DIR = BASE_DIR / "download_imp"
90
+ CALIB_JSON = MODEL_DIR / "calibration_params.json"
91
+ NORM_JSON = MODEL_DIR / "normalization_stats.json"
92
+ LOGS_DIR = BASE_DIR / "logs"
93
+ UPLOAD_BASE_DIR = os.environ.get("UPLOAD_BASE_DIR", str(BASE_DIR / "uploads"))
94
+
95
+ # ══════════════════════════════════════════════════════════════════════════
96
+ # CONFIGURATION
97
+ # ══════════════════════════════════════════════════════════════════════════
98
+
99
+ def _env_bool(name: str, default: bool) -> bool:
100
+ raw = os.environ.get(name)
101
+ return raw.strip().lower() in ("1", "true", "yes", "on") if raw else default
102
+
103
+ def _env_int(name: str, default: int, *, minimum: int | None = None) -> int:
104
+ raw = os.environ.get(name)
105
+ if not raw:
106
+ return default
107
+ try:
108
+ value = int(raw)
109
+ return value if minimum is None or value >= minimum else default
110
+ except ValueError:
111
+ return default
112
+
113
+ APP_DEBUG = _env_bool("ICH_APP_DEBUG", False)
114
+ APP_PORT = _env_int("ICH_APP_PORT", _env_int("PORT", 7860, minimum=1), minimum=1)
115
+ MAX_UPLOAD_MB = _env_int("ICH_MAX_UPLOAD_MB", 2048, minimum=1)
116
+ LOG_LEVEL_NAME = os.environ.get("ICH_LOG_LEVEL", "INFO").strip().upper()
117
+ LOG_LEVEL = getattr(logging, LOG_LEVEL_NAME, logging.INFO)
118
+ SECRET_KEY = os.environ.get("SECRET_KEY", os.environ.get("ICH_SECRET_KEY", "")).strip()
119
+ DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
120
+ HF_MODEL_REPO = os.environ.get("ICH_HF_MODEL_REPO", "").strip()
121
+ HF_TOKEN = os.environ.get("ICH_HF_TOKEN", "").strip()
122
+ LOCAL_MODE = _env_bool("ICH_LOCAL_MODE", True)
123
+
124
+ # ══════════════════════════════════════════════════════════════════════════
125
+ # FLASK APP SETUP
126
+ # ══════════════════════════════════════════════════════════════════════════
127
+
128
+ app = Flask(__name__, template_folder="templates", static_folder="static")
129
+
130
+ # Configuration
131
+ app.config.update(
132
+ MAX_CONTENT_LENGTH=MAX_UPLOAD_MB * 1024 * 1024,
133
+ SECRET_KEY=SECRET_KEY or os.urandom(32).hex(),
134
+ DEBUG=APP_DEBUG and os.environ.get("FLASK_ENV") == "development",
135
+ SQLALCHEMY_DATABASE_URI=DATABASE_URL or "sqlite:///ich_app.db",
136
+ SQLALCHEMY_TRACK_MODIFICATIONS=False,
137
+ SESSION_COOKIE_SECURE=not APP_DEBUG,
138
+ SESSION_COOKIE_HTTPONLY=True,
139
+ SESSION_COOKIE_SAMESITE="Lax",
140
+ PERMANENT_SESSION_LIFETIME=datetime.timedelta(days=30),
141
+ )
142
+
143
+ # Initialize extensions
144
+ db.init_app(app)
145
+ init_auth(app)
146
+ init_security(app)
147
+
148
+ # Register blueprints
149
+ app.register_blueprint(auth_bp)
150
+
151
+ # ══════════════════════════════════════════════════════════════════════════
152
+ # LOGGING
153
+ # ══════════════════════════════════════════════════════════════════════════
154
+
155
+ logging.basicConfig(
156
+ level=LOG_LEVEL,
157
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
158
+ )
159
+ logger = logging.getLogger("ich_app")
160
+
161
+ # ══════════════════════════════════════════════════════════════════════════
162
+ # DATABASE INITIALIZATION
163
+ # ══════════════════════════════════════════════════════════════════════════
164
+
165
+ def init_db():
166
+ """Initialize database tables"""
167
+ with app.app_context():
168
+ db.create_all()
169
+ logger.info("Database initialized")
170
+
171
+ # ══════════════════════════════════════════════════════════════════════════
172
+ # MODEL & INFERENCE STATE
173
+ # ══════════════════════════════════════════════════════════════════════════
174
+
175
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
176
+ bbr.configure(
177
+ include=["run_interface", "app"],
178
+ capture_args=True,
179
+ capture_returns=True,
180
+ sampling_rate=1.0,
181
+ )
182
+
183
+ _MODEL: dict[str, Any] = {
184
+ "loaded": False,
185
+ "model": None,
186
+ "grad_cam": None,
187
+ "loaded_folds": [],
188
+ "transform": None,
189
+ "device": None,
190
+ "temperature": None,
191
+ "calib_cfg": None,
192
+ "inference_mod": None,
193
+ }
194
+
195
+ _BATCHES: dict[str, dict[str, Any]] = {}
196
+ _BATCHES_LOCK = threading.Lock()
197
+
198
+ # ══════════════════════════════════════════════════════════════════════════
199
+ # MODEL LOADING
200
+ # ══════════════════════════════════════════════════════════════════════════
201
+
202
+ def _required_model_files(fold_selection: str) -> list[str]:
203
+ """Get list of required model files"""
204
+ files = ["calibration_params.json", "normalization_stats.json"]
205
+ raw = (fold_selection or "ensemble").strip().lower()
206
+ if raw in ("", "ensemble", "all"):
207
+ files.extend([f"best_model_fold{i}.pth" for i in range(5)])
208
+ elif raw == "best":
209
+ files.append("best_model_fold4.pth")
210
+ elif raw.isdigit():
211
+ files.append(f"best_model_fold{int(raw)}.pth")
212
+ else:
213
+ files.extend([f"best_model_fold{i}.pth" for i in range(5)])
214
+ return files
215
+
216
+ def _download_runtime_artifacts_if_needed(fold_selection: str) -> bool:
217
+ """Download missing model files from Hugging Face"""
218
+ required_files = _required_model_files(fold_selection)
219
+ missing = [f for f in required_files if not (MODEL_DIR / f).exists()]
220
+
221
+ if not missing:
222
+ return True
223
+ if not HF_MODEL_REPO or not hf_hub_download:
224
+ logger.warning(f"Missing model files and HF_MODEL_REPO not configured: {missing}")
225
+ return False
226
+
227
+ try:
228
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
229
+ for filename in missing:
230
+ logger.info(f"Downloading {filename}...")
231
+ hf_hub_download(
232
+ repo_id=HF_MODEL_REPO,
233
+ filename=filename,
234
+ repo_type="model",
235
+ local_dir=str(MODEL_DIR),
236
+ token=HF_TOKEN or None,
237
+ )
238
+ return True
239
+ except Exception as e:
240
+ logger.error(f"Failed downloading model artifacts: {e}")
241
+ return False
242
+
243
+ def _ensure_model_loaded() -> bool:
244
+ """Lazy-load ML model on first inference"""
245
+ if _MODEL["loaded"]:
246
+ return True
247
+
248
+ try:
249
+ import torch
250
+ sys.path.insert(0, str(BASE_DIR))
251
+
252
+ device = "cuda" if torch.cuda.is_available() else "cpu"
253
+ fold_selection = os.environ.get("ICH_FOLD_SELECTION", "ensemble")
254
+
255
+ if not _download_runtime_artifacts_if_needed(fold_selection):
256
+ return False
257
+
258
+ if not CALIB_JSON.exists():
259
+ logger.error(f"Calibration file not found: {CALIB_JSON}")
260
+ return False
261
+
262
+ with open(CALIB_JSON) as f:
263
+ calib_cfg = json.load(f)
264
+
265
+ if NORM_JSON.exists():
266
+ with open(NORM_JSON) as f:
267
+ norm = json.load(f)
268
+ mean = norm.get("mean_3ch", [0.162136, 0.141483, 0.183675])
269
+ std = norm.get("std_3ch", [0.312067, 0.283885, 0.305968])
270
+ else:
271
+ mean, std = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]
272
+
273
+ models, grad_cams, loaded_folds = ri.load_runtime_models(device, fold_selection)
274
+ if not models:
275
+ logger.error(f"Failed to load model checkpoints from {MODEL_DIR}")
276
+ return False
277
+
278
+ transform = ri.T.Compose([
279
+ ri.T.ToPILImage(),
280
+ ri.T.ToTensor(),
281
+ ri.T.Normalize(mean=mean, std=std),
282
+ ])
283
+
284
+ _MODEL.update({
285
+ "loaded": True,
286
+ "model": models,
287
+ "grad_cam": grad_cams,
288
+ "loaded_folds": loaded_folds,
289
+ "transform": transform,
290
+ "device": device,
291
+ "temperature": float(calib_cfg.get("temperature", 1.0)),
292
+ "calib_cfg": calib_cfg,
293
+ "inference_mod": ri,
294
+ })
295
+ logger.info(f"Model loaded: device={device}, folds={loaded_folds}")
296
+ return True
297
+
298
+ except Exception as e:
299
+ logger.error(f"Model loading failed: {e}", exc_info=True)
300
+ return False
301
+
302
+ # ══════════════════════════════════════════════════════════════════════════
303
+ # INFERENCE & BATCH PROCESSING
304
+ # ══════════════════════════════════════════════════════════════════════════
305
+
306
+ def _run_inference_on_dcm(dcm_path: Path, user_id: int) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
307
+ """Run inference on a single DICOM file"""
308
+ if not _ensure_model_loaded():
309
+ return None, None
310
+
311
+ ri_mod = _MODEL["inference_mod"]
312
+ image_id = dcm_path.stem
313
+ user_reports_dir = UserDataManager().get_user_reports_dir(user_id)
314
+
315
+ bbr.start()
316
+
317
+ try:
318
+ img_rgb = ri_mod.dicom_to_rgb(str(dcm_path), size=ri_mod.IMG_SIZE)
319
+ inference = ri_mod.infer_single(
320
+ img_rgb,
321
+ _MODEL["model"],
322
+ _MODEL["grad_cam"],
323
+ _MODEL["transform"],
324
+ _MODEL["device"],
325
+ _MODEL["temperature"],
326
+ )
327
+
328
+ user_reports_dir.mkdir(parents=True, exist_ok=True)
329
+ report = ri_mod.build_report(
330
+ image_id, inference, _MODEL["calib_cfg"],
331
+ user_reports_dir, img_rgb, true_label=None,
332
+ )
333
+
334
+ pred = report.get("prediction", {})
335
+ pred.setdefault("raw_probability", inference.get("raw_prob_any"))
336
+ pred.setdefault("calibrated_probability", inference.get("cal_prob_any"))
337
+ pred.setdefault("decision_threshold", pred.get("decision_threshold_any"))
338
+ report["prediction"] = pred
339
+
340
+ report_path = user_reports_dir / f"{image_id}_report.json"
341
+ with open(report_path, "w") as f:
342
+ json.dump(report, f, indent=2)
343
+
344
+ # Save to database
345
+ screening_report = ScreeningReport(
346
+ user_id=user_id,
347
+ upload_id=0, # Will be set by caller if needed
348
+ image_id=image_id,
349
+ screening_outcome=pred.get("screening_outcome"),
350
+ raw_probability=pred.get("raw_probability"),
351
+ calibrated_probability=pred.get("calibrated_probability"),
352
+ confidence_band=pred.get("confidence_band"),
353
+ decision_threshold=pred.get("decision_threshold"),
354
+ triage_action=report.get("triage", {}).get("action"),
355
+ urgency=report.get("triage", {}).get("urgency"),
356
+ report_json_path=str(report_path.relative_to(BASE_DIR)),
357
+ generated_at=datetime.datetime.utcnow(),
358
+ )
359
+ db.session.add(screening_report)
360
+ db.session.commit()
361
+
362
+ log_audit("inference_completed", user_id=user_id, resource_type="report",
363
+ resource_id=screening_report.id, status="success")
364
+
365
+ except Exception as e:
366
+ bbr.stop()
367
+ logger.error(f"Inference failed: {e}", exc_info=True)
368
+ log_audit("inference_failed", user_id=user_id, status="failure", details=str(e))
369
+ raise
370
+
371
+ bbr.stop()
372
+
373
+ # Save trace
374
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
375
+ base = f"{ts}_{image_id}"
376
+ try:
377
+ bbr.save_report(str(LOGS_DIR / f"{base}.txt"))
378
+ bbr.save_json(str(LOGS_DIR / f"{base}.json"))
379
+ except Exception as e:
380
+ logger.warning(f"Could not save trace: {e}")
381
+
382
+ return report, {"timestamp": ts, "image_id": image_id}
383
+
384
+ def _new_batch(user_id: int, total: int, temp_dir: str | None = None) -> str:
385
+ """Create a batch processing job"""
386
+ batch_id = uuid.uuid4().hex[:12]
387
+ with _BATCHES_LOCK:
388
+ _BATCHES[batch_id] = {
389
+ "user_id": user_id,
390
+ "status": "running",
391
+ "total": total,
392
+ "processed": 0,
393
+ "succeeded": 0,
394
+ "failed_ids": [],
395
+ "current_file": "",
396
+ "image_ids": [],
397
+ "started_at": datetime.datetime.now().isoformat(),
398
+ "finished_at": None,
399
+ "error": None,
400
+ "temp_dir": temp_dir,
401
+ }
402
+ return batch_id
403
+
404
+ def _batch_update(batch_id: str, **kw: Any) -> None:
405
+ """Update batch job status"""
406
+ with _BATCHES_LOCK:
407
+ if batch_id in _BATCHES:
408
+ _BATCHES[batch_id].update(kw)
409
+
410
+ def _run_batch_worker(batch_id: str, dcm_paths: list[Path], user_id: int):
411
+ """Process multiple DICOM files in background"""
412
+ succeeded_ids = []
413
+ failed_ids = []
414
+
415
+ for i, path in enumerate(dcm_paths, 1):
416
+ image_id = path.stem
417
+ _batch_update(batch_id, current_file=image_id, processed=i - 1)
418
+
419
+ try:
420
+ report, _ = _run_inference_on_dcm(path, user_id)
421
+ if report:
422
+ succeeded_ids.append(image_id)
423
+ else:
424
+ failed_ids.append(image_id)
425
+ except Exception as e:
426
+ logger.error(f"Batch {batch_id}: failed {image_id} β€” {e}")
427
+ failed_ids.append(image_id)
428
+
429
+ _batch_update(
430
+ batch_id,
431
+ processed=i,
432
+ succeeded=len(succeeded_ids),
433
+ image_ids=list(succeeded_ids),
434
+ failed_ids=list(failed_ids),
435
+ )
436
+
437
+ # Clean up
438
+ with _BATCHES_LOCK:
439
+ b = _BATCHES.get(batch_id, {})
440
+ td = b.get("temp_dir")
441
+ if td and Path(td).exists():
442
+ shutil.rmtree(td, ignore_errors=True)
443
+
444
+ _batch_update(
445
+ batch_id,
446
+ status="completed",
447
+ current_file="",
448
+ finished_at=datetime.datetime.now().isoformat(),
449
+ )
450
+ logger.info(f"Batch {batch_id} complete: {len(succeeded_ids)}/{len(dcm_paths)}, {len(failed_ids)} failed")
451
+
452
+ def _start_batch(dcm_paths: list[Path], user_id: int, temp_dir: str | None = None) -> str:
453
+ """Start async batch processing"""
454
+ batch_id = _new_batch(user_id, len(dcm_paths), temp_dir)
455
+ t = threading.Thread(
456
+ target=_run_batch_worker,
457
+ args=(batch_id, dcm_paths, user_id),
458
+ daemon=True,
459
+ name=f"batch-{batch_id}",
460
+ )
461
+ t.start()
462
+ return batch_id
463
+
464
+ # ══════════════════════════════════════════════════════════════════════════
465
+ # DATA MODEL & UTILITIES
466
+ # ══════════════════════════════════════════════════════════════════════════
467
+
468
+ @dataclass
469
+ class CaseRow:
470
+ """Display row for screening report"""
471
+ image_id: str = ""
472
+ outcome: str = "Unknown"
473
+ raw_prob: float | None = None
474
+ cal_prob: float | None = None
475
+ band: str = "N/A"
476
+ triage: str = "N/A"
477
+ urgency: str = "N/A"
478
+ generated_at: str = ""
479
+ report_file: str | None = None
480
+ gradcam_file: str | None = None
481
+
482
+ @property
483
+ def date_display(self) -> str:
484
+ if not self.generated_at:
485
+ return "β€”"
486
+ try:
487
+ dt = datetime.datetime.fromisoformat(self.generated_at)
488
+ return dt.strftime("%Y-%m-%d %H:%M")
489
+ except (ValueError, TypeError):
490
+ return self.generated_at[:16]
491
+
492
+ @property
493
+ def is_positive(self) -> bool:
494
+ return "no hemorrhage" not in self.outcome.lower()
495
+
496
+ def _load_user_cases(user_id: int) -> list[CaseRow]:
497
+ """Load user's screening reports from database"""
498
+ reports = ScreeningReport.query.filter_by(user_id=user_id).order_by(
499
+ ScreeningReport.generated_at.desc()
500
+ ).all()
501
+
502
+ cases = []
503
+ for r in reports:
504
+ cases.append(CaseRow(
505
+ image_id=r.image_id,
506
+ outcome=r.screening_outcome or "Unknown",
507
+ raw_prob=r.raw_probability,
508
+ cal_prob=r.calibrated_probability,
509
+ band=r.confidence_band or "N/A",
510
+ triage=r.triage_action or "N/A",
511
+ urgency=r.urgency or "N/A",
512
+ generated_at=r.generated_at.isoformat() if r.generated_at else "",
513
+ report_file=Path(r.report_json_path).name if r.report_json_path else None,
514
+ ))
515
+
516
+ return cases
517
+
518
+ def compute_stats(rows: list[CaseRow]) -> dict[str, Any]:
519
+ """Compute statistics for dashboard"""
520
+ total = len(rows)
521
+ positive = sum(1 for r in rows if r.is_positive)
522
+ urgent = sum(1 for r in rows if r.urgency.upper() == "URGENT")
523
+ cal_probs = [r.cal_prob for r in rows if r.cal_prob is not None]
524
+ avg_cal = sum(cal_probs) / len(cal_probs) if cal_probs else 0.0
525
+ pos_rate = (positive / total * 100) if total else 0.0
526
+
527
+ return {
528
+ "total": total,
529
+ "positive": positive,
530
+ "negative": total - positive,
531
+ "urgent": urgent,
532
+ "avg_cal_prob": avg_cal,
533
+ "pos_rate": pos_rate,
534
+ "heatmaps": sum(1 for r in rows if r.gradcam_file),
535
+ }
536
+
537
+
538
+ def _load_calibration() -> dict[str, Any]:
539
+ """Load calibration file safely for template rendering."""
540
+ if not CALIB_JSON.exists():
541
+ return {}
542
+ try:
543
+ with open(CALIB_JSON, "r", encoding="utf-8") as f:
544
+ calib = json.load(f)
545
+ # Add backward-compatible aliases expected by templates
546
+ return {
547
+ **calib,
548
+ "method": calib.get("method", calib.get("best_method", "N/A")),
549
+ "temperature": calib.get("temperature", 1.0),
550
+ "raw_ece": calib.get("ece_raw", 0.0),
551
+ "cal_ece": calib.get("ece_isotonic", calib.get("ece_temp", 0.0)),
552
+ "raw_brier": calib.get("brier_raw", 0.0),
553
+ "cal_brier": calib.get("brier_isotonic", calib.get("brier_temp", 0.0)),
554
+ "calibrated_threshold": calib.get("threshold_at_spec90", 0.5),
555
+ "base_threshold": calib.get("base_threshold", 0.5),
556
+ "high_threshold": calib.get("high_threshold", calib.get("triage_high_thresh", 0.7)),
557
+ "low_threshold": calib.get("low_threshold", calib.get("triage_low_thresh", 0.3)),
558
+ }
559
+ except (OSError, json.JSONDecodeError):
560
+ return {}
561
+
562
+
563
+ def _load_normalization() -> dict[str, Any]:
564
+ """Load normalization statistics safely for template rendering."""
565
+ if not NORM_JSON.exists():
566
+ return {}
567
+ try:
568
+ with open(NORM_JSON, "r", encoding="utf-8") as f:
569
+ data = json.load(f)
570
+ except (OSError, json.JSONDecodeError):
571
+ return {}
572
+
573
+ mean = data.get("mean_3ch") or data.get("mean")
574
+ std = data.get("std_3ch") or data.get("std")
575
+ return {
576
+ "mean": mean,
577
+ "std": std,
578
+ "n_images": data.get("n_images"),
579
+ }
580
+
581
+ # ══════════════════════════════════════════════════════════════════════════
582
+ # MIDDLEWARE
583
+ # ══════════════════════════════════════════════════════════════════════════
584
+
585
+ @app.before_request
586
+ def _log_request(): # pyright: ignore[reportUnusedFunction]
587
+ g._start = time.perf_counter()
588
+ g._client_info = get_client_ip()
589
+
590
+ @app.after_request
591
+ def _log_response(response): # pyright: ignore[reportUnusedFunction]
592
+ elapsed = (time.perf_counter() - getattr(g, "_start", time.perf_counter())) * 1000
593
+ logger.info(
594
+ f"{request.method} {request.path} -> {response.status_code} ({elapsed:.1f}ms) from {getattr(g, '_client_info', 'unknown')}"
595
+ )
596
+ return response
597
+
598
+ # ══════════════════════════════════════════════════════════════════════════
599
+ # ROUTES
600
+ # ══════════════════════════════════════════════════════════════════════════
601
+
602
+ @app.route("/")
603
+ def home():
604
+ """Home page"""
605
+ if not current_user.is_authenticated:
606
+ return redirect(url_for("auth.login"))
607
+
608
+ cases = _load_user_cases(current_user.id)
609
+ stats = compute_stats(cases)
610
+
611
+ log_audit("page_view_home", user_id=current_user.id, status="success")
612
+ return render_template("home.html", stats=stats, user=current_user)
613
+
614
+ @app.route("/upload", methods=["GET"])
615
+ @login_required
616
+ def upload():
617
+ """Upload page"""
618
+ return render_template("upload.html", local_mode=LOCAL_MODE)
619
+
620
+ @app.route("/analyze", methods=["POST"])
621
+ @login_required
622
+ def analyze():
623
+ """Process uploaded DICOM files"""
624
+ # Check rate limit
625
+ is_limited, msg = check_upload_rate_limit(current_user.id)
626
+ if is_limited:
627
+ log_audit("upload_rate_limited", user_id=current_user.id, status="failure")
628
+ return jsonify({"error": msg}), 429
629
+
630
+ files = request.files.getlist("file")
631
+ files = [f for f in files if f.filename]
632
+
633
+ if not files:
634
+ flash("No files were uploaded.", "error")
635
+ return redirect(url_for("upload"))
636
+
637
+ user_upload_dir = UserDataManager().get_user_upload_dir(current_user.id)
638
+ user_upload_dir.mkdir(parents=True, exist_ok=True)
639
+
640
+ dcm_paths: list[Path] = []
641
+ temp_dir: str | None = None
642
+
643
+ for f in files:
644
+ filename = f.filename or ""
645
+ fname = filename.lower()
646
+
647
+ if fname.endswith(".zip"):
648
+ temp_dir = tempfile.mkdtemp(prefix="ich_zip_")
649
+ zip_path = Path(temp_dir) / secure_filename(filename)
650
+ f.save(str(zip_path))
651
+ try:
652
+ with zipfile.ZipFile(zip_path, "r") as zf:
653
+ zf.extractall(temp_dir)
654
+ dcm_paths.extend(sorted(Path(temp_dir).rglob("*.dcm")))
655
+ except zipfile.BadZipFile:
656
+ shutil.rmtree(temp_dir, ignore_errors=True)
657
+ log_audit("upload_failed", user_id=current_user.id,
658
+ status="failure", details="Bad ZIP file")
659
+ flash("The uploaded ZIP file is corrupted.", "error")
660
+ return redirect(url_for("upload"))
661
+
662
+ elif fname.endswith(".dcm"):
663
+ safe = sanitize_filename(filename)
664
+ save_path = user_upload_dir / safe
665
+ f.save(str(save_path))
666
+ dcm_paths.append(save_path)
667
+
668
+ if not dcm_paths:
669
+ if temp_dir:
670
+ shutil.rmtree(temp_dir, ignore_errors=True)
671
+ log_audit("upload_no_dcm", user_id=current_user.id, status="failure")
672
+ flash("No .dcm files found in the upload.", "error")
673
+ return redirect(url_for("upload"))
674
+
675
+ # Single file - synchronous
676
+ if len(dcm_paths) == 1 and temp_dir is None:
677
+ path = dcm_paths[0]
678
+ try:
679
+ report, _ = _run_inference_on_dcm(path, current_user.id)
680
+ if not report:
681
+ flash("Model failed to load. Check server logs.", "error")
682
+ return redirect(url_for("upload"))
683
+ return redirect(url_for("case_detail", image_id=path.stem))
684
+ except Exception as e:
685
+ logger.error(f"Analysis failed: {e}")
686
+ log_audit("analysis_failed", user_id=current_user.id, status="failure", details=str(e))
687
+ flash(f"Analysis failed: {e}", "error")
688
+ return redirect(url_for("upload"))
689
+ finally:
690
+ if path.exists() and path.parent == user_upload_dir:
691
+ path.unlink()
692
+
693
+ # Multiple files - async batch
694
+ batch_id = _start_batch(dcm_paths, current_user.id, temp_dir)
695
+ log_audit("batch_started", user_id=current_user.id,
696
+ details=f"batch_id={batch_id}, files={len(dcm_paths)}")
697
+ return redirect(url_for("batch_progress", batch_id=batch_id))
698
+
699
+
700
+ @app.route("/analyze/directory", methods=["POST"])
701
+ @login_required
702
+ def analyze_directory():
703
+ """Local-only route for scanning a server-side directory of DICOM files."""
704
+ if not LOCAL_MODE:
705
+ abort(403)
706
+
707
+ dir_path_str = request.form.get("dir_path", "").strip()
708
+ if not dir_path_str:
709
+ flash("Please enter a directory path.", "error")
710
+ return redirect(url_for("upload"))
711
+
712
+ scan_dir = Path(dir_path_str)
713
+ if not scan_dir.is_dir():
714
+ flash(f"Directory not found: {dir_path_str}", "error")
715
+ return redirect(url_for("upload"))
716
+
717
+ dcm_paths = sorted(scan_dir.rglob("*.dcm"))
718
+ if not dcm_paths:
719
+ flash(f"No .dcm files found in: {dir_path_str}", "error")
720
+ return redirect(url_for("upload"))
721
+
722
+ batch_id = _start_batch(dcm_paths, current_user.id)
723
+ log_audit("directory_batch_started", user_id=current_user.id, details=f"batch_id={batch_id}, files={len(dcm_paths)}")
724
+ return redirect(url_for("batch_progress", batch_id=batch_id))
725
+
726
+ @app.route("/batch/<batch_id>")
727
+ @login_required
728
+ def batch_progress(batch_id):
729
+ """Batch processing progress page"""
730
+ with _BATCHES_LOCK:
731
+ batch = _BATCHES.get(batch_id)
732
+ if not batch or batch.get("user_id") != current_user.id:
733
+ abort(404)
734
+ batch_copy = dict(batch)
735
+
736
+ return render_template("batch_progress.html", batch=batch_copy, batch_id=batch_id)
737
+
738
+ @app.route("/batch/<batch_id>/status")
739
+ @login_required
740
+ def batch_status(batch_id):
741
+ """Get batch status (JSON API)"""
742
+ with _BATCHES_LOCK:
743
+ batch = _BATCHES.get(batch_id)
744
+ if not batch or batch.get("user_id") != current_user.id:
745
+ return jsonify({"error": "Not found"}), 404
746
+ return jsonify(batch)
747
+
748
+ @app.route("/reports")
749
+ @login_required
750
+ def reports():
751
+ """User's screening reports"""
752
+ route_start = time.perf_counter()
753
+ cases = _load_user_cases(current_user.id)
754
+ total_cases = len(cases)
755
+
756
+ # Filtering
757
+ q = request.args.get("q", "").strip()
758
+ band = request.args.get("band", "")
759
+ urgency = request.args.get("urgency", "")
760
+ outcome = request.args.get("outcome", "")
761
+ sort_by = request.args.get("sort", "date_desc")
762
+ try:
763
+ page = max(1, int(request.args.get("page", "1") or 1))
764
+ except ValueError:
765
+ page = 1
766
+ try:
767
+ page_size = int(request.args.get("page_size", "50") or 50)
768
+ except ValueError:
769
+ page_size = 50
770
+ if page_size not in (10, 50, 100):
771
+ page_size = 50
772
+
773
+ if q:
774
+ ql = q.lower()
775
+ cases = [c for c in cases if ql in c.image_id.lower() or ql in c.outcome.lower()]
776
+ if band:
777
+ cases = [c for c in cases if c.band.upper() == band.upper()]
778
+ if urgency:
779
+ cases = [c for c in cases if c.urgency.upper() == urgency.upper()]
780
+ if outcome == "POSITIVE":
781
+ cases = [c for c in cases if c.is_positive]
782
+ elif outcome == "NEGATIVE":
783
+ cases = [c for c in cases if not c.is_positive]
784
+
785
+ if sort_by == "date_desc":
786
+ cases = sorted(cases, key=lambda c: c.generated_at or "", reverse=True)
787
+ elif sort_by == "date_asc":
788
+ cases = sorted(cases, key=lambda c: c.generated_at or "")
789
+ elif sort_by == "prob_desc":
790
+ cases = sorted(cases, key=lambda c: c.cal_prob or 0, reverse=True)
791
+ elif sort_by == "prob_asc":
792
+ cases = sorted(cases, key=lambda c: c.cal_prob or 0)
793
+
794
+ stats = compute_stats(cases)
795
+ total_items = len(cases)
796
+ total_pages = max(1, math.ceil(total_items / page_size))
797
+ page = min(page, total_pages)
798
+ page_start = (page - 1) * page_size
799
+ rows = cases[page_start: page_start + page_size]
800
+ route_compute_ms = (time.perf_counter() - route_start) * 1000
801
+
802
+ return render_template(
803
+ "reports.html",
804
+ rows=rows,
805
+ cases=rows,
806
+ stats=stats,
807
+ calib=_load_calibration(),
808
+ q=q,
809
+ band=band,
810
+ urgency=urgency,
811
+ outcome=outcome,
812
+ sort=sort_by,
813
+ sort_by=sort_by,
814
+ page=page,
815
+ page_size=page_size,
816
+ page_start=page_start,
817
+ total_pages=total_pages,
818
+ total_items=total_items,
819
+ total_cases=total_cases,
820
+ route_compute_ms=route_compute_ms,
821
+ data_refresh_ms=0,
822
+ data_cache_hit=False,
823
+ )
824
+
825
+ @app.route("/case/<image_id>")
826
+ @login_required
827
+ def case_detail(image_id):
828
+ """View screening report details"""
829
+ report = ScreeningReport.query.filter_by(user_id=current_user.id, image_id=image_id).first()
830
+ if not report:
831
+ abort(404)
832
+
833
+ user_reports_dir = UserDataManager().get_user_reports_dir(current_user.id)
834
+ report_path = user_reports_dir / f"{image_id}_report.json"
835
+
836
+ if not report_path.exists():
837
+ abort(404)
838
+
839
+ try:
840
+ with open(report_path) as f:
841
+ report_data = json.load(f)
842
+ except (json.JSONDecodeError, OSError):
843
+ abort(500)
844
+
845
+ log_audit("report_viewed", user_id=current_user.id, resource_type="report", resource_id=report.id)
846
+ return render_template("detail.html", report=report_data)
847
+
848
+ @app.route("/logs")
849
+ @login_required
850
+ def logs_page():
851
+ """View user's inference logs"""
852
+ log_files = []
853
+
854
+ if LOGS_DIR.exists():
855
+ for path in sorted(LOGS_DIR.iterdir(), reverse=True)[:50]: # Last 50 logs
856
+ if path.suffix in (".txt", ".json"):
857
+ log_files.append({
858
+ "name": path.name,
859
+ "size": round(path.stat().st_size / 1024, 1),
860
+ "modified": datetime.datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
861
+ })
862
+
863
+ return render_template("logs.html", logs=log_files)
864
+
865
+ @app.route("/about")
866
+ def about():
867
+ """About page"""
868
+ return render_template("about.html", calib=_load_calibration())
869
+
870
+ @app.route("/evaluation")
871
+ def evaluation():
872
+ """Model evaluation page"""
873
+ cases = _load_user_cases(current_user.id) if current_user.is_authenticated else []
874
+ cal_probs = [r.cal_prob for r in cases if r.cal_prob is not None]
875
+
876
+ bins = [0] * 10
877
+ for p in cal_probs:
878
+ bins[min(int(p * 10), 9)] += 1
879
+
880
+ band_data: dict[str, dict[str, int]] = {}
881
+ for bnd in ("HIGH", "MEDIUM", "LOW"):
882
+ subset = [r for r in cases if r.band.upper() == bnd]
883
+ positive = sum(1 for r in subset if r.is_positive)
884
+ band_data[bnd] = {
885
+ "total": len(subset),
886
+ "positive": positive,
887
+ "negative": len(subset) - positive,
888
+ }
889
+
890
+ return render_template(
891
+ "evaluation.html",
892
+ stats=compute_stats(cases),
893
+ calib=_load_calibration(),
894
+ norm=_load_normalization(),
895
+ bins=bins,
896
+ band_data=band_data,
897
+ total=len(cases),
898
+ )
899
+
900
+
901
+ @app.route("/gradcam/<path:filename>")
902
+ @login_required
903
+ def serve_gradcam(filename: str):
904
+ """Serve a user's Grad-CAM image from their report directory."""
905
+ safe_name = Path(filename).name
906
+ reports_dir = UserDataManager().get_user_reports_dir(current_user.id)
907
+ return send_from_directory(reports_dir, safe_name)
908
+
909
+ @app.errorhandler(401)
910
+ def unauthorized(e):
911
+ if request.path.startswith("/api/"):
912
+ return jsonify({"error": "Unauthorized"}), 401
913
+ return redirect(url_for("auth.login"))
914
+
915
+ @app.errorhandler(403)
916
+ def forbidden(e):
917
+ if request.path.startswith("/api/"):
918
+ return jsonify({"error": "Forbidden"}), 403
919
+ flash("Access denied", "error")
920
+ return redirect(url_for("home"))
921
+
922
+ @app.errorhandler(404)
923
+ def not_found(e):
924
+ if request.path.startswith("/api/"):
925
+ return jsonify({"error": "Not found"}), 404
926
+ return render_template("404.html"), 404
927
+
928
+ @app.errorhandler(500)
929
+ def server_error(e):
930
+ logger.error(f"Server error: {e}", exc_info=True)
931
+ if request.path.startswith("/api/"):
932
+ return jsonify({"error": "Server error"}), 500
933
+ return render_template("500.html"), 500
934
+
935
+ # ══════════════════════════════════════════════════════════════════════════
936
+ # CLI COMMANDS
937
+ # ══════════════════════════════════════════════════════════════════════════
938
+
939
+ @app.cli.command()
940
+ def init_db_cmd():
941
+ """Initialize database"""
942
+ init_db()
943
+ print("Database initialized!")
944
+
945
+ @app.cli.command()
946
+ def create_admin():
947
+ """Create admin user (interactive)"""
948
+ from getpass import getpass
949
+
950
+ username = input("Username: ").strip()
951
+ email = input("Email: ").strip()
952
+ password = getpass("Password: ")
953
+
954
+ if User.query.filter_by(username=username).first():
955
+ print("User already exists!")
956
+ return
957
+
958
+ user = User(username=username, email=email, full_name="Admin")
959
+ user.set_password(password)
960
+ db.session.add(user)
961
+ db.session.commit()
962
+ print(f"Admin user '{username}' created!")
963
+
964
+ # ══════════════════════════════════════════════════════════════════════════
965
+ # MAIN
966
+ # ══════════════════════════════════════════════════════════════════════════
967
+
968
+ if __name__ == "__main__":
969
+ with app.app_context():
970
+ init_db()
971
+
972
+ app.run(host="0.0.0.0", port=APP_PORT, debug=APP_DEBUG)
auth_routes.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication routes: login, register, logout
3
+ """
4
+ import logging
5
+ from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
6
+ from flask_login import login_user, logout_user, current_user
7
+ from models import db, User
8
+ from auth_utils import (
9
+ validate_username, validate_password, validate_email, log_audit
10
+ )
11
+
12
+ logger = logging.getLogger(__name__)
13
+ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
14
+
15
+
16
+ @auth_bp.route('/register', methods=['GET', 'POST'])
17
+ def register():
18
+ """User registration"""
19
+ if current_user.is_authenticated:
20
+ return redirect(url_for('home'))
21
+
22
+ if request.method == 'POST':
23
+ username = request.form.get('username', '').strip()
24
+ email = request.form.get('email', '').strip().lower()
25
+ password = request.form.get('password', '')
26
+ confirm_password = request.form.get('confirm_password', '')
27
+ full_name = request.form.get('full_name', '').strip()
28
+
29
+ # Validate inputs
30
+ valid, msg = validate_username(username)
31
+ if not valid:
32
+ flash(msg, 'error')
33
+ return render_template('auth/register.html'), 400
34
+
35
+ valid, msg = validate_email(email)
36
+ if not valid:
37
+ flash(msg, 'error')
38
+ return render_template('auth/register.html'), 400
39
+
40
+ if password != confirm_password:
41
+ flash('Passwords do not match', 'error')
42
+ return render_template('auth/register.html'), 400
43
+
44
+ valid, msg = validate_password(password)
45
+ if not valid:
46
+ flash(msg, 'error')
47
+ return render_template('auth/register.html'), 400
48
+
49
+ # Check if user exists
50
+ if User.query.filter_by(username=username).first():
51
+ flash('Username already exists', 'error')
52
+ return render_template('auth/register.html'), 400
53
+
54
+ if User.query.filter_by(email=email).first():
55
+ flash('Email already registered', 'error')
56
+ return render_template('auth/register.html'), 400
57
+
58
+ try:
59
+ # Create new user
60
+ user = User(
61
+ username=username,
62
+ email=email,
63
+ full_name=full_name
64
+ )
65
+ user.set_password(password)
66
+
67
+ db.session.add(user)
68
+ db.session.commit()
69
+
70
+ log_audit('user_registered', user_id=user.id, status='success')
71
+
72
+ flash('Registration successful! Please log in.', 'success')
73
+ return redirect(url_for('auth.login'))
74
+
75
+ except Exception as e:
76
+ db.session.rollback()
77
+ logger.error(f"Registration error: {e}")
78
+ log_audit('user_registration_failed', status='failure', details=str(e))
79
+ flash('Registration failed. Please try again.', 'error')
80
+ return render_template('auth/register.html'), 500
81
+
82
+ return render_template('auth/register.html')
83
+
84
+
85
+ @auth_bp.route('/login', methods=['GET', 'POST'])
86
+ def login():
87
+ """User login"""
88
+ if current_user.is_authenticated:
89
+ return redirect(url_for('home'))
90
+
91
+ if request.method == 'POST':
92
+ username = request.form.get('username', '').strip()
93
+ password = request.form.get('password', '')
94
+ remember = request.form.get('remember', False)
95
+
96
+ user = User.query.filter_by(username=username).first()
97
+
98
+ if not user:
99
+ logger.warning(f"Login attempt with non-existent username: {username}")
100
+ log_audit('login_failed', status='failure', details=f'User not found: {username}')
101
+ flash('Invalid username or password', 'error')
102
+ return render_template('auth/login.html'), 401
103
+
104
+ if not user.is_active:
105
+ log_audit('login_failed', user_id=user.id, status='failure', details='Account inactive')
106
+ flash('Your account has been deactivated', 'error')
107
+ return render_template('auth/login.html'), 403
108
+
109
+ if not user.check_password(password):
110
+ logger.warning(f"Failed login attempt for user: {username}")
111
+ log_audit('login_failed', user_id=user.id, status='failure', details='Invalid password')
112
+ flash('Invalid username or password', 'error')
113
+ return render_template('auth/login.html'), 401
114
+
115
+ try:
116
+ login_user(user, remember=remember)
117
+ log_audit('login_success', user_id=user.id, status='success')
118
+
119
+ next_page = request.args.get('next')
120
+ if next_page and next_page.startswith('/'):
121
+ return redirect(next_page)
122
+ return redirect(url_for('home'))
123
+
124
+ except Exception as e:
125
+ logger.error(f"Login error: {e}")
126
+ log_audit('login_error', user_id=user.id, status='failure', details=str(e))
127
+ flash('Login failed. Please try again.', 'error')
128
+ return render_template('auth/login.html'), 500
129
+
130
+ return render_template('auth/login.html')
131
+
132
+
133
+ @auth_bp.route('/logout', methods=['POST'])
134
+ def logout():
135
+ """User logout"""
136
+ if current_user.is_authenticated:
137
+ log_audit('logout', user_id=current_user.id, status='success')
138
+ logout_user()
139
+ return redirect(url_for('auth.login'))
140
+
141
+
142
+ @auth_bp.route('/forgot-password', methods=['GET', 'POST'])
143
+ def forgot_password():
144
+ """Forgot password β€” shows a polished form; no email is sent (SMTP not configured)."""
145
+ if current_user.is_authenticated:
146
+ return redirect(url_for('home'))
147
+
148
+ if request.method == 'POST':
149
+ email = request.form.get('email', '').strip().lower()
150
+ # We always return the same response to prevent user enumeration.
151
+ logger.info(f"Password reset requested for email: {email}")
152
+ log_audit('password_reset_requested', status='info', details=f'Email: {email}')
153
+ # Redirect with ?sent=1 so the template can show the success state
154
+ return redirect(url_for('auth.forgot_password') + '?sent=1')
155
+
156
+ return render_template('auth/forgot_password.html')
157
+
158
+
159
+ @auth_bp.route('/profile', methods=['GET'])
160
+ def profile():
161
+ """View user profile"""
162
+ if not current_user.is_authenticated:
163
+ return redirect(url_for('auth.login'))
164
+
165
+ return render_template('auth/profile.html', user=current_user)
166
+
167
+
168
+ @auth_bp.route('/change-password', methods=['POST'])
169
+ def change_password():
170
+ """Change user password"""
171
+ if not current_user.is_authenticated:
172
+ return redirect(url_for('auth.login'))
173
+
174
+ if not request.is_json:
175
+ return jsonify({'error': 'Content-Type must be application/json'}), 400
176
+
177
+ data = request.get_json()
178
+ current_password = data.get('current_password', '')
179
+ new_password = data.get('new_password', '')
180
+ confirm_password = data.get('confirm_password', '')
181
+
182
+ if not current_user.check_password(current_password):
183
+ log_audit('password_change_failed', user_id=current_user.id,
184
+ status='failure', details='Invalid current password')
185
+ return jsonify({'error': 'Current password is incorrect'}), 401
186
+
187
+ if new_password != confirm_password:
188
+ return jsonify({'error': 'New passwords do not match'}), 400
189
+
190
+ valid, msg = validate_password(new_password)
191
+ if not valid:
192
+ return jsonify({'error': msg}), 400
193
+
194
+ try:
195
+ current_user.set_password(new_password)
196
+ db.session.commit()
197
+ log_audit('password_changed', user_id=current_user.id, status='success')
198
+ return jsonify({'message': 'Password changed successfully'}), 200
199
+
200
+ except Exception as e:
201
+ db.session.rollback()
202
+ logger.error(f"Password change error: {e}")
203
+ log_audit('password_change_error', user_id=current_user.id,
204
+ status='failure', details=str(e))
205
+ return jsonify({'error': 'Password change failed'}), 500
auth_utils.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication utilities and decorators for user management and security
3
+ """
4
+ import os
5
+ import logging
6
+ from functools import wraps
7
+ from flask import session, redirect, url_for, request, g, abort
8
+ from flask_login import LoginManager, current_user
9
+ from models import db, User, AuditLog
10
+ from datetime import datetime
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ login_manager = LoginManager()
15
+
16
+
17
+ def init_auth(app):
18
+ """Initialize authentication system"""
19
+ login_manager.init_app(app)
20
+ login_manager.login_view = 'auth.login'
21
+ login_manager.login_message = 'Please log in to access this page.'
22
+ login_manager.login_message_category = 'info'
23
+
24
+
25
+ @login_manager.user_loader
26
+ def load_user(user_id):
27
+ """Load user from database by ID"""
28
+ return User.query.get(int(user_id))
29
+
30
+
31
+ def get_client_ip():
32
+ """Extract client IP address from request"""
33
+ if request.headers.get('X-Forwarded-For'):
34
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
35
+ return request.remote_addr or 'unknown'
36
+
37
+
38
+ def log_audit(action, user_id=None, resource_type=None, resource_id=None,
39
+ details=None, status='success'):
40
+ """Log action to audit trail"""
41
+ try:
42
+ audit_entry = AuditLog(
43
+ user_id=user_id,
44
+ action=action,
45
+ resource_type=resource_type,
46
+ resource_id=resource_id,
47
+ details=details,
48
+ ip_address=get_client_ip(),
49
+ timestamp=datetime.utcnow(),
50
+ status=status
51
+ )
52
+ db.session.add(audit_entry)
53
+ db.session.commit()
54
+ except Exception as e:
55
+ logger.error(f"Failed to log audit entry: {e}")
56
+ # Don't raise - audit failures shouldn't break the app
57
+
58
+
59
+ def login_required_with_audit(f):
60
+ """Decorator that requires login and logs the access"""
61
+ @wraps(f)
62
+ def decorated_function(*args, **kwargs):
63
+ if not current_user.is_authenticated:
64
+ log_audit('access_denied', status='failure', details=f'Unauthorized access to {request.path}')
65
+ return redirect(url_for('auth.login'))
66
+ return f(*args, **kwargs)
67
+ return decorated_function
68
+
69
+
70
+ def require_json_content_type(f):
71
+ """Decorator to ensure request has JSON content type"""
72
+ @wraps(f)
73
+ def decorated_function(*args, **kwargs):
74
+ if request.method in ['POST', 'PUT', 'PATCH']:
75
+ if not request.is_json:
76
+ return {'error': 'Content-Type must be application/json'}, 400
77
+ return f(*args, **kwargs)
78
+ return decorated_function
79
+
80
+
81
+ def validate_username(username):
82
+ """Validate username format"""
83
+ if not username or len(username) < 3 or len(username) > 80:
84
+ return False, "Username must be between 3 and 80 characters"
85
+ if not all(c.isalnum() or c in '_-' for c in username):
86
+ return False, "Username can only contain letters, numbers, underscores, and hyphens"
87
+ return True, ""
88
+
89
+
90
+ def validate_password(password):
91
+ """Validate password strength"""
92
+ if not password or len(password) < 8:
93
+ return False, "Password must be at least 8 characters long"
94
+ if len(password) > 128:
95
+ return False, "Password must be less than 128 characters"
96
+ # Check for at least one uppercase, one lowercase, one digit
97
+ has_upper = any(c.isupper() for c in password)
98
+ has_lower = any(c.islower() for c in password)
99
+ has_digit = any(c.isdigit() for c in password)
100
+ if not (has_upper and has_lower and has_digit):
101
+ return False, "Password must contain uppercase, lowercase, and digits"
102
+ return True, ""
103
+
104
+
105
+ def validate_email(email):
106
+ """Basic email validation"""
107
+ import re
108
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
109
+ if not re.match(pattern, email):
110
+ return False, "Invalid email format"
111
+ return True, ""
data_isolation.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data isolation and file management for user-specific screening data
3
+ Ensures users can only access their own files and data
4
+ """
5
+ import os
6
+ import logging
7
+ from pathlib import Path
8
+ from flask_login import current_user
9
+ from models import db, ScreeningUpload, ScreeningReport
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class UserDataManager:
15
+ """Manages user-specific data storage and access control"""
16
+
17
+ def __init__(self, base_upload_dir: str = "uploads"):
18
+ self.base_upload_dir = Path(base_upload_dir)
19
+ self.base_upload_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ def get_user_upload_dir(self, user_id: int) -> Path:
22
+ """Get the uploads directory for a specific user"""
23
+ user_dir = self.base_upload_dir / f"user_{user_id}" / "uploads"
24
+ user_dir.mkdir(parents=True, exist_ok=True)
25
+ return user_dir
26
+
27
+ def get_user_reports_dir(self, user_id: int) -> Path:
28
+ """Get the reports directory for a specific user"""
29
+ reports_dir = self.base_upload_dir / f"user_{user_id}" / "reports"
30
+ reports_dir.mkdir(parents=True, exist_ok=True)
31
+ return reports_dir
32
+
33
+ def get_user_data_dir(self, user_id: int) -> Path:
34
+ """Get the root data directory for a specific user"""
35
+ data_dir = self.base_upload_dir / f"user_{user_id}"
36
+ data_dir.mkdir(parents=True, exist_ok=True)
37
+ return data_dir
38
+
39
+ def get_current_user_dir(self) -> Path:
40
+ """Get upload directory for the current authenticated user"""
41
+ if not current_user.is_authenticated:
42
+ raise PermissionError("User not authenticated")
43
+ return self.get_user_upload_dir(current_user.id)
44
+
45
+ def get_current_user_reports_dir(self) -> Path:
46
+ """Get reports directory for the current authenticated user"""
47
+ if not current_user.is_authenticated:
48
+ raise PermissionError("User not authenticated")
49
+ return self.get_user_reports_dir(current_user.id)
50
+
51
+ @staticmethod
52
+ def verify_file_ownership(user_id: int, file_path: str) -> bool:
53
+ """
54
+ Verify that a file belongs to the specified user.
55
+ Prevents directory traversal attacks.
56
+ """
57
+ user_data_dir = Path("uploads") / f"user_{user_id}"
58
+ try:
59
+ file_full_path = user_data_dir.resolve() / file_path
60
+ # Ensure the resolved path is still within the user's directory
61
+ return str(file_full_path).startswith(str(user_data_dir.resolve()))
62
+ except Exception:
63
+ return False
64
+
65
+ @staticmethod
66
+ def verify_upload_ownership(user_id: int, upload_id: int) -> bool:
67
+ """Verify that an upload record belongs to the specified user"""
68
+ upload = ScreeningUpload.query.filter_by(id=upload_id, user_id=user_id).first()
69
+ return upload is not None
70
+
71
+ @staticmethod
72
+ def verify_report_ownership(user_id: int, report_id: int) -> bool:
73
+ """Verify that a report record belongs to the specified user"""
74
+ report = ScreeningReport.query.filter_by(id=report_id, user_id=user_id).first()
75
+ return report is not None
76
+
77
+ @staticmethod
78
+ def get_user_uploads(user_id: int, limit: int = None):
79
+ """Get all uploads for a user with optional limit"""
80
+ query = ScreeningUpload.query.filter_by(user_id=user_id).order_by(
81
+ ScreeningUpload.upload_timestamp.desc()
82
+ )
83
+ if limit:
84
+ query = query.limit(limit)
85
+ return query.all()
86
+
87
+ @staticmethod
88
+ def get_user_reports(user_id: int, limit: int = None):
89
+ """Get all reports for a user with optional limit"""
90
+ query = ScreeningReport.query.filter_by(user_id=user_id).order_by(
91
+ ScreeningReport.generated_at.desc()
92
+ )
93
+ if limit:
94
+ query = query.limit(limit)
95
+ return query.all()
96
+
97
+ @staticmethod
98
+ def get_report_statistics(user_id: int) -> dict:
99
+ """Get statistics about a user's reports"""
100
+ reports = ScreeningReport.query.filter_by(user_id=user_id).all()
101
+
102
+ total = len(reports)
103
+ positive = len([r for r in reports if r.urgency and 'urgent' in r.urgency.lower()])
104
+ negative = total - positive
105
+
106
+ avg_cal_prob = 0
107
+ if total > 0:
108
+ avg_cal_prob = sum(r.calibrated_probability or 0 for r in reports) / total
109
+
110
+ return {
111
+ 'total': total,
112
+ 'positive': positive,
113
+ 'negative': negative,
114
+ 'avg_cal_prob': avg_cal_prob,
115
+ 'pos_rate': (positive / total * 100) if total > 0 else 0
116
+ }
117
+
118
+
119
+ class SecureFileAccess:
120
+ """Handles secure file access with permission checks"""
121
+
122
+ @staticmethod
123
+ def is_path_safe(base_dir: Path, requested_path: Path) -> bool:
124
+ """
125
+ Verify that requested_path is within base_dir.
126
+ Prevents directory traversal attacks.
127
+ """
128
+ try:
129
+ # Resolve both paths to absolute to prevent symlink tricks
130
+ base_resolved = base_dir.resolve()
131
+ path_resolved = (base_dir / requested_path).resolve()
132
+
133
+ # Check if the resolved path is within the base directory
134
+ path_resolved.relative_to(base_resolved)
135
+ return True
136
+ except ValueError:
137
+ return False
138
+
139
+ @staticmethod
140
+ def get_user_file(user_id: int, file_path: str):
141
+ """
142
+ Safely retrieve a file that belongs to the user.
143
+ Returns None if file doesn't exist or user doesn't own it.
144
+ """
145
+ if not UserDataManager.verify_file_ownership(user_id, file_path):
146
+ logger.warning(f"Unauthorized file access attempt by user {user_id}: {file_path}")
147
+ return None
148
+
149
+ user_data_dir = Path("uploads") / f"user_{user_id}"
150
+ full_path = (user_data_dir / file_path).resolve()
151
+
152
+ if not full_path.exists() or not full_path.is_file():
153
+ return None
154
+
155
+ return full_path
156
+
157
+ @staticmethod
158
+ def delete_user_file(user_id: int, file_path: str) -> bool:
159
+ """
160
+ Safely delete a file that belongs to the user.
161
+ Returns True if successful, False otherwise.
162
+ """
163
+ file_to_delete = SecureFileAccess.get_user_file(user_id, file_path)
164
+ if not file_to_delete:
165
+ return False
166
+
167
+ try:
168
+ file_to_delete.unlink()
169
+ logger.info(f"Deleted file for user {user_id}: {file_path}")
170
+ return True
171
+ except Exception as e:
172
+ logger.error(f"Failed to delete file for user {user_id}: {e}")
173
+ return False
174
+
175
+
176
+ def require_user_ownership(resource_type: str):
177
+ """
178
+ Decorator to verify user ownership of resources before processing.
179
+
180
+ Args:
181
+ resource_type: 'upload' or 'report'
182
+ """
183
+ from functools import wraps
184
+ from flask import request, abort
185
+
186
+ def decorator(f):
187
+ @wraps(f)
188
+ def decorated_function(*args, **kwargs):
189
+ if not current_user.is_authenticated:
190
+ abort(401)
191
+
192
+ resource_id = request.view_args.get('id')
193
+ if not resource_id:
194
+ abort(400)
195
+
196
+ try:
197
+ resource_id = int(resource_id)
198
+ except (ValueError, TypeError):
199
+ abort(400)
200
+
201
+ if resource_type == 'upload':
202
+ if not UserDataManager.verify_upload_ownership(current_user.id, resource_id):
203
+ abort(403)
204
+ elif resource_type == 'report':
205
+ if not UserDataManager.verify_report_ownership(current_user.id, resource_id):
206
+ abort(403)
207
+ else:
208
+ abort(400)
209
+
210
+ return f(*args, **kwargs)
211
+
212
+ return decorated_function
213
+ return decorator
models.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database models for ICH Screening Application with user authentication and privacy
3
+ """
4
+ import os
5
+ from datetime import datetime
6
+ from flask_sqlalchemy import SQLAlchemy
7
+ from flask_login import UserMixin
8
+ from werkzeug.security import generate_password_hash, check_password_hash
9
+ import secrets
10
+
11
+ db = SQLAlchemy()
12
+
13
+
14
+ class User(UserMixin, db.Model):
15
+ """User account model for authentication"""
16
+ __tablename__ = 'users'
17
+
18
+ id = db.Column(db.Integer, primary_key=True)
19
+ username = db.Column(db.String(80), unique=True, nullable=False, index=True)
20
+ email = db.Column(db.String(120), unique=True, nullable=False, index=True)
21
+ password_hash = db.Column(db.String(255), nullable=False)
22
+ full_name = db.Column(db.String(120))
23
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
24
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
25
+ is_active = db.Column(db.Boolean, default=True, nullable=False)
26
+
27
+ # Relationships
28
+ screening_uploads = db.relationship('ScreeningUpload', backref='user', lazy=True, cascade='all, delete-orphan')
29
+ screening_reports = db.relationship('ScreeningReport', backref='user', lazy=True, cascade='all, delete-orphan')
30
+
31
+ def set_password(self, password):
32
+ """Hash and set the user's password"""
33
+ self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
34
+
35
+ def check_password(self, password):
36
+ """Verify password against stored hash"""
37
+ return check_password_hash(self.password_hash, password)
38
+
39
+ def __repr__(self):
40
+ return f'<User {self.username}>'
41
+
42
+
43
+ class ScreeningUpload(db.Model):
44
+ """Track uploaded DICOM files with user ownership"""
45
+ __tablename__ = 'screening_uploads'
46
+
47
+ id = db.Column(db.Integer, primary_key=True)
48
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
49
+ file_name = db.Column(db.String(255), nullable=False)
50
+ original_filename = db.Column(db.String(255), nullable=False)
51
+ file_size = db.Column(db.Integer) # bytes
52
+ file_path = db.Column(db.String(500), nullable=False) # Relative to user's upload dir
53
+ upload_timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
54
+ processing_status = db.Column(db.String(20), default='pending') # pending, processing, completed, failed
55
+ processing_error = db.Column(db.Text) # Error message if failed
56
+
57
+ # Relationships
58
+ reports = db.relationship('ScreeningReport', backref='upload', lazy=True, cascade='all, delete-orphan')
59
+
60
+ def __repr__(self):
61
+ return f'<ScreeningUpload {self.id} - user {self.user_id}>'
62
+
63
+
64
+ class ScreeningReport(db.Model):
65
+ """Store screening results with full user isolation"""
66
+ __tablename__ = 'screening_reports'
67
+
68
+ id = db.Column(db.Integer, primary_key=True)
69
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
70
+ upload_id = db.Column(db.Integer, db.ForeignKey('screening_uploads.id'), nullable=False, index=True)
71
+ image_id = db.Column(db.String(100), nullable=False)
72
+
73
+ # Prediction results
74
+ screening_outcome = db.Column(db.String(100))
75
+ raw_probability = db.Column(db.Float)
76
+ calibrated_probability = db.Column(db.Float)
77
+ confidence_band = db.Column(db.String(50))
78
+ decision_threshold = db.Column(db.Float)
79
+
80
+ # Triage information
81
+ triage_action = db.Column(db.String(100))
82
+ urgency = db.Column(db.String(50))
83
+
84
+ # Ground truth (for validation only)
85
+ true_label = db.Column(db.String(100))
86
+
87
+ # File paths (relative to user's data dir)
88
+ report_json_path = db.Column(db.String(500))
89
+ gradcam_image_path = db.Column(db.String(500))
90
+
91
+ # Generated timestamp
92
+ generated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
93
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
94
+
95
+ def __repr__(self):
96
+ return f'<ScreeningReport {self.id} - user {self.user_id} - {self.image_id}>'
97
+
98
+
99
+ class AuditLog(db.Model):
100
+ """Audit trail for security and compliance"""
101
+ __tablename__ = 'audit_logs'
102
+
103
+ id = db.Column(db.Integer, primary_key=True)
104
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
105
+ action = db.Column(db.String(100), nullable=False) # login, logout, upload, delete, download, etc.
106
+ resource_type = db.Column(db.String(50)) # upload, report, etc.
107
+ resource_id = db.Column(db.Integer)
108
+ details = db.Column(db.Text) # JSON or plain text with additional info
109
+ ip_address = db.Column(db.String(45)) # IPv4 or IPv6
110
+ timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
111
+ status = db.Column(db.String(20), default='success') # success, failure
112
+
113
+ def __repr__(self):
114
+ return f'<AuditLog {self.action} - user {self.user_id} - {self.timestamp}>'
render.yaml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: intracranial-hemorrhage-detection
4
+ env: python
5
+ plan: free
6
+ buildCommand: pip install -r requirements.txt
7
+ startCommand: gunicorn app:app --bind 0.0.0.0:$PORT --workers 1 --timeout 180
8
+ envVars:
9
+ - key: ICH_APP_DEBUG
10
+ value: "0"
11
+ - key: ICH_LOCAL_MODE
12
+ value: "0"
13
+ - key: ICH_MAX_UPLOAD_MB
14
+ value: "256"
15
+ - key: ICH_HF_MODEL_REPO
16
+ value: "HarshCode/eff_b4_brain"
requirements.txt CHANGED
@@ -1,17 +1,34 @@
1
- flask
2
- werkzeug
 
 
3
 
4
- numpy
5
- pandas
6
- opencv-python
7
- pydicom
 
8
 
9
- torch
10
- timm
11
- scikit-learn
 
 
12
 
13
- blackbox-recorder
14
- python-dotenv
15
- huggingface_hub
 
 
 
 
 
16
 
 
 
 
 
 
 
 
17
 
 
1
+ # Web Framework & Server
2
+ flask>=3.0.0
3
+ werkzeug>=3.0.0
4
+ gunicorn>=21.0.0
5
 
6
+ # Database & ORM
7
+ SQLAlchemy>=2.0.0
8
+ psycopg2-binary>=2.9.0
9
+ flask-sqlalchemy>=3.1.0
10
+ flask-migrate>=4.0.0
11
 
12
+ # Authentication & Security
13
+ flask-login>=0.6.0
14
+ bcrypt>=4.1.0
15
+ python-dotenv>=1.0.0
16
+ cryptography>=41.0.0
17
 
18
+ # Data Processing & ML
19
+ numpy>=1.24.0
20
+ pandas>=2.0.0
21
+ opencv-python>=4.8.0
22
+ pydicom>=2.4.0
23
+ torch>=2.0.0
24
+ timm>=0.9.0
25
+ scikit-learn>=1.3.0
26
 
27
+ # Debugging & Monitoring
28
+ blackbox-recorder>=0.2.0
29
+ huggingface_hub>=0.17.0
30
+
31
+ # Additional utilities
32
+ requests>=2.31.0
33
+ python-dateutil>=2.8.0
34
 
security.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities: headers, CSRF protection, input validation
3
+ """
4
+ import os
5
+ import logging
6
+ from flask import request
7
+ from datetime import datetime, timedelta
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def init_security(app):
13
+ """Initialize security features for Flask app"""
14
+
15
+ @app.before_request
16
+ def set_security_headers():
17
+ """Add security headers to all responses"""
18
+ pass # Headers are set in after_request
19
+
20
+ @app.after_request
21
+ def add_security_headers(response):
22
+ """Add security headers to all responses"""
23
+
24
+ # Prevent clickjacking attacks
25
+ response.headers['X-Frame-Options'] = 'SAMEORIGIN'
26
+
27
+ # Prevent MIME type sniffing
28
+ response.headers['X-Content-Type-Options'] = 'nosniff'
29
+
30
+ # Enable XSS protection in older browsers
31
+ response.headers['X-XSS-Protection'] = '1; mode=block'
32
+
33
+ # Content Security Policy - restrictive but functional
34
+ csp = (
35
+ "default-src 'self'; "
36
+ "script-src 'self' 'unsafe-inline'; " # Minimal unsafe-inline for compatibility
37
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
38
+ "font-src 'self' https://fonts.gstatic.com; "
39
+ "img-src 'self' data:; "
40
+ "connect-src 'self'; "
41
+ "frame-ancestors 'self'; "
42
+ "base-uri 'self'; "
43
+ "form-action 'self'"
44
+ )
45
+ response.headers['Content-Security-Policy'] = csp
46
+
47
+ # Referrer policy
48
+ response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
49
+
50
+ # Feature policy / Permissions policy
51
+ response.headers['Permissions-Policy'] = (
52
+ 'geolocation=(), microphone=(), camera=(), usb=(), payment=()'
53
+ )
54
+
55
+ # HSTS (HTTP Strict-Transport-Security) - only on HTTPS
56
+ if request.is_secure or os.environ.get('FLASK_ENV') == 'production':
57
+ response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
58
+
59
+ return response
60
+
61
+ # Session security
62
+ app.config.update(
63
+ SESSION_COOKIE_SECURE=os.environ.get('FLASK_ENV') == 'production',
64
+ SESSION_COOKIE_HTTPONLY=True,
65
+ SESSION_COOKIE_SAMESITE='Lax',
66
+ PERMANENT_SESSION_LIFETIME=timedelta(days=30),
67
+ )
68
+
69
+ logger.info("Security headers and features initialized")
70
+
71
+
72
+ def sanitize_filename(filename: str, max_length: int = 255) -> str:
73
+ """
74
+ Sanitize filename to prevent directory traversal and other attacks.
75
+
76
+ Args:
77
+ filename: The filename to sanitize
78
+ max_length: Maximum length for the sanitized filename
79
+
80
+ Returns:
81
+ Safe filename
82
+ """
83
+ import re
84
+
85
+ # Remove any path components
86
+ filename = os.path.basename(filename)
87
+
88
+ # Remove null bytes
89
+ filename = filename.replace('\0', '')
90
+
91
+ # Allow only safe characters (alphanumeric, dash, underscore, dot)
92
+ filename = re.sub(r'[^\w\-\.]', '_', filename)
93
+
94
+ # Remove leading/trailing dots and spaces
95
+ filename = filename.strip('. ')
96
+
97
+ # Prevent empty filename
98
+ if not filename:
99
+ filename = 'file'
100
+
101
+ # Limit length
102
+ if len(filename) > max_length:
103
+ # Preserve extension
104
+ name, ext = os.path.splitext(filename)
105
+ filename = name[:max_length - len(ext)] + ext
106
+
107
+ return filename
108
+
109
+
110
+ def validate_file_extension(filename: str, allowed_extensions: list) -> bool:
111
+ """
112
+ Validate that file has an allowed extension.
113
+
114
+ Args:
115
+ filename: The filename to validate
116
+ allowed_extensions: List of allowed extensions (without dots)
117
+
118
+ Returns:
119
+ True if extension is allowed, False otherwise
120
+ """
121
+ if not filename or '.' not in filename:
122
+ return False
123
+
124
+ ext = filename.rsplit('.', 1)[-1].lower()
125
+ return ext in [e.lower() for e in allowed_extensions]
126
+
127
+
128
+ def mask_sensitive_data(data: dict, fields_to_mask: list) -> dict:
129
+ """
130
+ Mask sensitive fields in a dictionary before logging or sending to client.
131
+
132
+ Args:
133
+ data: Dictionary containing data to mask
134
+ fields_to_mask: List of field names to mask
135
+
136
+ Returns:
137
+ Dictionary with masked fields
138
+ """
139
+ import copy
140
+
141
+ masked = copy.deepcopy(data)
142
+ for field in fields_to_mask:
143
+ if field in masked:
144
+ value = str(masked[field])
145
+ if len(value) > 4:
146
+ masked[field] = value[:2] + '*' * (len(value) - 4) + value[-2:]
147
+ else:
148
+ masked[field] = '*' * len(value)
149
+
150
+ return masked
151
+
152
+
153
+ def get_client_info() -> dict:
154
+ """Extract client information from request for logging"""
155
+ return {
156
+ 'ip_address': request.remote_addr,
157
+ 'user_agent': request.headers.get('User-Agent', 'Unknown'),
158
+ 'endpoint': request.endpoint,
159
+ 'method': request.method,
160
+ 'timestamp': datetime.utcnow().isoformat()
161
+ }
162
+
163
+
164
+ class RateLimiter:
165
+ """Simple in-memory rate limiter for protecting against abuse"""
166
+
167
+ def __init__(self, max_requests: int = 100, window_seconds: int = 60):
168
+ self.max_requests = max_requests
169
+ self.window_seconds = window_seconds
170
+ self.requests = {} # {key: [(timestamp, count), ...]}
171
+
172
+ def is_rate_limited(self, key: str) -> bool:
173
+ """Check if a key has exceeded rate limit"""
174
+ now = datetime.utcnow()
175
+ window_start = now - timedelta(seconds=self.window_seconds)
176
+
177
+ # Clean old entries
178
+ if key in self.requests:
179
+ self.requests[key] = [
180
+ (ts, count) for ts, count in self.requests[key]
181
+ if ts > window_start
182
+ ]
183
+
184
+ # Count requests in window
185
+ total_requests = sum(count for _, count in self.requests.get(key, []))
186
+
187
+ return total_requests >= self.max_requests
188
+
189
+ def record_request(self, key: str):
190
+ """Record a request for rate limiting"""
191
+ now = datetime.utcnow()
192
+
193
+ if key not in self.requests:
194
+ self.requests[key] = []
195
+
196
+ # Add or increment the count for this second
197
+ if self.requests[key] and self.requests[key][-1][0] == now:
198
+ ts, count = self.requests[key][-1]
199
+ self.requests[key][-1] = (ts, count + 1)
200
+ else:
201
+ self.requests[key].append((now, 1))
202
+
203
+
204
+ # Global rate limiter instances
205
+ login_rate_limiter = RateLimiter(max_requests=5, window_seconds=300) # 5 attempts in 5 minutes
206
+ upload_rate_limiter = RateLimiter(max_requests=20, window_seconds=3600) # 20 uploads per hour
207
+
208
+
209
+ def check_login_rate_limit(identifier: str) -> tuple[bool, str]:
210
+ """
211
+ Check if login attempt should be rate limited.
212
+ Returns (is_limited, message)
213
+ """
214
+ if login_rate_limiter.is_rate_limited(identifier):
215
+ return True, "Too many login attempts. Please try again later."
216
+
217
+ login_rate_limiter.record_request(identifier)
218
+ return False, ""
219
+
220
+
221
+ def check_upload_rate_limit(user_id: int) -> tuple[bool, str]:
222
+ """
223
+ Check if upload should be rate limited.
224
+ Returns (is_limited, message)
225
+ """
226
+ key = f"upload_{user_id}"
227
+ if upload_rate_limiter.is_rate_limited(key):
228
+ return True, "Upload rate limit exceeded. Maximum 20 uploads per hour."
229
+
230
+ upload_rate_limiter.record_request(key)
231
+ return False, ""
static/css/auth.css ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════
2
+ ICH Screening β€” Auth Pages CSS
3
+ Split-layout, glassmorphism, micro-animations
4
+ ═══════════════════════════════════════════════════ */
5
+
6
+ /* ── Auth root ──────────────────────────────────── */
7
+ .auth-page {
8
+ min-height: 100vh;
9
+ display: flex;
10
+ background: #070d1a;
11
+ overflow: hidden;
12
+ }
13
+
14
+ /* ── Left brand panel ───────────────────────────── */
15
+ .auth-brand {
16
+ display: flex;
17
+ flex-direction: column;
18
+ justify-content: center;
19
+ padding: 56px 52px;
20
+ width: 44%;
21
+ position: relative;
22
+ overflow: hidden;
23
+ background:
24
+ radial-gradient(ellipse 700px 500px at 15% 25%, rgba(110,168,254,.14) 0%, transparent 70%),
25
+ radial-gradient(ellipse 500px 500px at 85% 80%, rgba(99,102,241,.12) 0%, transparent 65%),
26
+ linear-gradient(145deg, #0a1628 0%, #0d1f3c 50%, #0c1427 100%);
27
+ border-right: 1px solid rgba(36,51,86,.6);
28
+ }
29
+ .auth-brand::before {
30
+ content: '';
31
+ position: absolute;
32
+ width: 380px; height: 380px;
33
+ border-radius: 50%;
34
+ background: radial-gradient(circle, rgba(110,168,254,.07) 0%, transparent 70%);
35
+ top: -80px; left: -80px;
36
+ animation: orb 7s ease-in-out infinite;
37
+ }
38
+ .auth-brand::after {
39
+ content: '';
40
+ position: absolute;
41
+ width: 280px; height: 280px;
42
+ border-radius: 50%;
43
+ background: radial-gradient(circle, rgba(99,102,241,.09) 0%, transparent 70%);
44
+ bottom: -60px; right: -60px;
45
+ animation: orb 9s ease-in-out infinite reverse;
46
+ }
47
+ @keyframes orb {
48
+ 0%,100%{ transform:scale(1); opacity:.8; }
49
+ 50% { transform:scale(1.18); opacity:1; }
50
+ }
51
+
52
+ .auth-brand-logo {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 12px;
56
+ margin-bottom: 48px;
57
+ position: relative; z-index: 1;
58
+ }
59
+ .auth-brand-icon {
60
+ width: 42px; height: 42px;
61
+ border-radius: 12px;
62
+ background: linear-gradient(135deg, #6ea8fe 0%, #6366f1 100%);
63
+ display: flex; align-items: center; justify-content: center;
64
+ box-shadow: 0 0 22px rgba(110,168,254,.4);
65
+ }
66
+ .auth-brand-icon svg { color: #fff; }
67
+ .auth-brand-name {
68
+ font-size: 1.05rem; font-weight: 800;
69
+ color: #e8ecf6; letter-spacing: -.02em;
70
+ }
71
+
72
+ .auth-headline {
73
+ position: relative; z-index: 1; margin-bottom: 20px;
74
+ }
75
+ .auth-headline h2 {
76
+ font-size: 2rem; font-weight: 800;
77
+ line-height: 1.2; letter-spacing: -.03em;
78
+ color: #e8ecf6; margin-bottom: 12px;
79
+ }
80
+ .auth-headline h2 .grad {
81
+ background: linear-gradient(135deg, #6ea8fe 0%, #a78bfa 100%);
82
+ -webkit-background-clip: text;
83
+ -webkit-text-fill-color: transparent;
84
+ background-clip: text;
85
+ }
86
+ .auth-headline p {
87
+ font-size: .95rem; color: #8ba0c4;
88
+ line-height: 1.65; max-width: 320px;
89
+ }
90
+
91
+ .auth-features {
92
+ list-style: none; padding: 0; margin: 32px 0 0;
93
+ display: flex; flex-direction: column; gap: 14px;
94
+ position: relative; z-index: 1;
95
+ }
96
+ .auth-features li {
97
+ display: flex; align-items: center; gap: 12px;
98
+ font-size: .9rem; color: #8ba0c4;
99
+ }
100
+ .feat-icon {
101
+ width: 32px; height: 32px; border-radius: 9px;
102
+ background: rgba(110,168,254,.1);
103
+ border: 1px solid rgba(110,168,254,.2);
104
+ display: flex; align-items: center; justify-content: center;
105
+ flex-shrink: 0; color: #6ea8fe;
106
+ }
107
+
108
+ .auth-illustration {
109
+ position: relative; z-index: 1;
110
+ margin-top: 44px; display: flex; justify-content: center;
111
+ }
112
+ .auth-illustration svg {
113
+ filter: drop-shadow(0 0 28px rgba(110,168,254,.25));
114
+ animation: float-scan 4s ease-in-out infinite;
115
+ }
116
+ @keyframes float-scan {
117
+ 0%,100%{ transform:translateY(0); }
118
+ 50% { transform:translateY(-10px); }
119
+ }
120
+
121
+ /* ── Right form panel ───────────────────────────── */
122
+ .auth-form-panel {
123
+ flex: 1;
124
+ display: flex; flex-direction: column;
125
+ justify-content: center; align-items: center;
126
+ padding: 48px 40px;
127
+ overflow-y: auto;
128
+ }
129
+ .auth-card {
130
+ width: 100%; max-width: 420px;
131
+ animation: slide-in .4s cubic-bezier(.16,1,.3,1) both;
132
+ }
133
+ @keyframes slide-in {
134
+ from{ opacity:0; transform:translateX(22px); }
135
+ to { opacity:1; transform:translateX(0); }
136
+ }
137
+ .auth-card-header { margin-bottom: 28px; }
138
+ .auth-card-header h2 {
139
+ font-size: 1.7rem; font-weight: 800;
140
+ color: #e8ecf6; letter-spacing: -.03em; margin-bottom: 5px;
141
+ }
142
+ .auth-card-header p { color: #8ba0c4; font-size: .9rem; }
143
+
144
+ /* ── Alerts ──────────────────────────────────────── */
145
+ .auth-alerts { display:flex; flex-direction:column; gap:10px; margin-bottom:18px; }
146
+ .alert {
147
+ display:flex; align-items:flex-start; gap:10px;
148
+ padding:11px 15px; border-radius:10px;
149
+ font-size:.87rem; line-height:1.5;
150
+ animation:alert-in .3s ease;
151
+ }
152
+ @keyframes alert-in { from{opacity:0;transform:translateY(-7px);} to{opacity:1;transform:translateY(0);} }
153
+ .alert-error { background:rgba(251,113,133,.1); border:1px solid rgba(251,113,133,.3); color:#fb7185; }
154
+ .alert-success { background:rgba(52,211,153,.1); border:1px solid rgba(52,211,153,.3); color:#34d399; }
155
+ .alert-info { background:rgba(110,168,254,.1); border:1px solid rgba(110,168,254,.3); color:#6ea8fe; }
156
+
157
+ /* ── Form ────────────────────────────────────────── */
158
+ .auth-form { display:flex; flex-direction:column; gap:16px; }
159
+ .form-group { display:flex; flex-direction:column; gap:6px; }
160
+ .form-group label {
161
+ font-size:.78rem; font-weight:700;
162
+ color:#8ba0c4; text-transform:uppercase; letter-spacing:.06em;
163
+ }
164
+ .form-hint { font-size:.76rem; color:#3d5482; margin-top:2px; }
165
+
166
+ .input-wrap { position:relative; }
167
+ .input-icon {
168
+ position:absolute; left:13px; top:50%; transform:translateY(-50%);
169
+ color:#3d5482; pointer-events:none; transition:color .2s;
170
+ }
171
+ .input-wrap input {
172
+ width:100%;
173
+ background:rgba(11,18,36,.8);
174
+ color:#e8ecf6;
175
+ border:1px solid #243356;
176
+ border-radius:11px;
177
+ padding:12px 13px 12px 40px;
178
+ font-size:.92rem; font-family:inherit;
179
+ transition:border-color .2s,box-shadow .2s,background .2s;
180
+ -webkit-appearance:none;
181
+ }
182
+ .input-wrap input::placeholder { color:#3d5482; }
183
+ .input-wrap input:focus {
184
+ outline:none;
185
+ border-color:#6ea8fe;
186
+ background:rgba(15,24,50,.9);
187
+ box-shadow:0 0 0 3px rgba(110,168,254,.12);
188
+ }
189
+ .input-wrap:focus-within .input-icon { color:#6ea8fe; }
190
+ .input-wrap input.has-toggle { padding-right:40px; }
191
+
192
+ .btn-pw-toggle {
193
+ position:absolute; right:11px; top:50%; transform:translateY(-50%);
194
+ background:transparent; border:none; color:#3d5482;
195
+ cursor:pointer; padding:4px; border-radius:6px;
196
+ display:flex; align-items:center; transition:color .2s;
197
+ }
198
+ .btn-pw-toggle:hover { color:#6ea8fe; background:transparent; border:none; box-shadow:none; }
199
+
200
+ /* password strength */
201
+ .pw-strength-bar {
202
+ height:3px; border-radius:3px; background:#162244; overflow:hidden; margin-top:6px;
203
+ }
204
+ .pw-strength-fill {
205
+ height:100%; border-radius:3px; transition:width .35s ease,background .35s ease; width:0%;
206
+ }
207
+ .pw-strength-text { font-size:.73rem; margin-top:3px; font-weight:700; }
208
+ .pw-strength-fill.weak { width:25%; background:#fb7185; }
209
+ .pw-strength-fill.fair { width:55%; background:#fbbf24; }
210
+ .pw-strength-fill.good { width:78%; background:#34d399; }
211
+ .pw-strength-fill.strong { width:100%; background:#6ea8fe; }
212
+ .pw-strength-text.weak { color:#fb7185; }
213
+ .pw-strength-text.fair { color:#fbbf24; }
214
+ .pw-strength-text.good { color:#34d399; }
215
+ .pw-strength-text.strong { color:#6ea8fe; }
216
+
217
+ /* remember / forgot row */
218
+ .auth-row {
219
+ display:flex; align-items:center;
220
+ justify-content:space-between; flex-wrap:wrap; gap:8px;
221
+ }
222
+ .form-check { display:flex; align-items:center; gap:7px; cursor:pointer; }
223
+ .form-check-input { width:15px; height:15px; accent-color:#6ea8fe; cursor:pointer; }
224
+ .form-check-label { font-size:.86rem; color:#8ba0c4; cursor:pointer; }
225
+ .auth-link-sm {
226
+ font-size:.83rem; color:#6ea8fe; text-decoration:none; transition:color .2s;
227
+ }
228
+ .auth-link-sm:hover { color:#a8c9ff; text-decoration:underline; }
229
+
230
+ /* submit button */
231
+ .btn-auth-submit {
232
+ width:100%; padding:13px;
233
+ background:linear-gradient(135deg,#6ea8fe 0%,#6366f1 100%);
234
+ color:#fff; border:none; border-radius:12px;
235
+ font-size:.93rem; font-weight:700; font-family:inherit;
236
+ cursor:pointer; letter-spacing:.01em; margin-top:4px;
237
+ box-shadow:0 4px 20px rgba(110,168,254,.3);
238
+ transition:opacity .2s,transform .15s,box-shadow .2s;
239
+ }
240
+ .btn-auth-submit:hover {
241
+ opacity:.9; transform:translateY(-1px);
242
+ box-shadow:0 6px 26px rgba(110,168,254,.4);
243
+ border:none; background:linear-gradient(135deg,#6ea8fe 0%,#6366f1 100%);
244
+ }
245
+ .btn-auth-submit:active { transform:translateY(0); }
246
+
247
+ /* footer */
248
+ .auth-footer {
249
+ margin-top:24px; text-align:center;
250
+ font-size:.87rem; color:#8ba0c4;
251
+ }
252
+ .auth-footer a { color:#6ea8fe; font-weight:600; text-decoration:none; transition:color .2s; }
253
+ .auth-footer a:hover { color:#a8c9ff; }
254
+
255
+ /* ── Profile ─────────────────────────────────────── */
256
+ .profile-page { max-width:660px; margin:0 auto; padding-top:16px; }
257
+ .profile-hero {
258
+ display:flex; align-items:center; gap:22px;
259
+ margin-bottom:24px; padding:26px 28px;
260
+ background:linear-gradient(135deg,#111c33 0%,#162244 100%);
261
+ border:1px solid #243356; border-radius:20px; position:relative; overflow:hidden;
262
+ }
263
+ .profile-hero::before {
264
+ content:''; position:absolute; inset:0;
265
+ background:radial-gradient(ellipse 400px 250px at 0% 0%,rgba(110,168,254,.07),transparent);
266
+ pointer-events:none;
267
+ }
268
+ .profile-avatar {
269
+ width:68px; height:68px; border-radius:50%;
270
+ background:linear-gradient(135deg,#6ea8fe,#6366f1);
271
+ display:flex; align-items:center; justify-content:center;
272
+ font-size:1.7rem; font-weight:800; color:#fff; flex-shrink:0;
273
+ box-shadow:0 0 0 4px rgba(110,168,254,.15),0 0 22px rgba(110,168,254,.28);
274
+ }
275
+ .profile-identity h2 {
276
+ font-size:1.35rem; font-weight:800; color:#e8ecf6; letter-spacing:-.02em;
277
+ }
278
+ .profile-identity .profile-email { color:#8ba0c4; font-size:.88rem; margin-top:2px; }
279
+ .profile-badge {
280
+ display:inline-flex; align-items:center; gap:5px; margin-top:8px;
281
+ font-size:.75rem; color:#6ea8fe; font-weight:700;
282
+ background:rgba(110,168,254,.1); border:1px solid rgba(110,168,254,.2);
283
+ border-radius:999px; padding:3px 11px;
284
+ }
285
+
286
+ .profile-section {
287
+ background:linear-gradient(180deg,#162244 0%,#111c33 100%);
288
+ border:1px solid #243356; border-radius:16px;
289
+ padding:22px 26px; margin-bottom:14px;
290
+ }
291
+ .profile-section h3 {
292
+ font-size:.78rem; font-weight:700; color:#8ba0c4;
293
+ text-transform:uppercase; letter-spacing:.07em; margin-bottom:16px;
294
+ display:flex; align-items:center; gap:8px;
295
+ }
296
+ .profile-section h3 svg { color:#6ea8fe; }
297
+ .profile-row {
298
+ display:flex; justify-content:space-between; align-items:center;
299
+ padding:10px 0; border-bottom:1px solid rgba(36,51,86,.5);
300
+ font-size:.9rem; gap:12px;
301
+ }
302
+ .profile-row:last-child { border-bottom:none; }
303
+ .pr-label { color:#8ba0c4; font-weight:500; }
304
+ .pr-value { color:#e8ecf6; font-weight:600; text-align:right; }
305
+
306
+ /* inline pw form */
307
+ .pw-change-section { display:flex; flex-direction:column; gap:12px; margin-top:4px; }
308
+ .pw-toggle-btn {
309
+ display:inline-flex; align-items:center; gap:7px;
310
+ background:transparent; border:1px solid #243356; color:#6ea8fe;
311
+ font-size:.86rem; padding:8px 16px; border-radius:9px; cursor:pointer;
312
+ font-family:inherit; font-weight:600; transition:all .2s; width:fit-content;
313
+ }
314
+ .pw-toggle-btn:hover { background:rgba(110,168,254,.08); border-color:#6ea8fe; }
315
+ .pw-change-fields { display:none; flex-direction:column; gap:12px; }
316
+ .pw-change-fields.active { display:flex; }
317
+ .pw-action-row { display:flex; gap:9px; flex-wrap:wrap; }
318
+ .btn-save-pw {
319
+ padding:9px 20px; background:linear-gradient(135deg,#6ea8fe,#6366f1);
320
+ color:#fff; border:none; border-radius:9px;
321
+ font-weight:700; font-family:inherit; cursor:pointer; font-size:.86rem;
322
+ transition:opacity .2s,transform .15s;
323
+ }
324
+ .btn-save-pw:hover { opacity:.88; transform:translateY(-1px); border:none; }
325
+ .btn-cancel-pw {
326
+ padding:9px 20px; background:transparent; border:1px solid #243356;
327
+ color:#8ba0c4; border-radius:9px; font-weight:600;
328
+ font-family:inherit; cursor:pointer; font-size:.86rem; transition:all .2s;
329
+ }
330
+ .btn-cancel-pw:hover { border-color:#6ea8fe; color:#6ea8fe; background:transparent; }
331
+ #pwMessage { font-size:.83rem; padding:9px 13px; border-radius:9px; display:none; }
332
+ #pwMessage.success { display:block; background:rgba(52,211,153,.1); border:1px solid rgba(52,211,153,.3); color:#34d399; }
333
+ #pwMessage.error { display:block; background:rgba(251,113,133,.1); border:1px solid rgba(251,113,133,.3); color:#fb7185; }
334
+
335
+ /* danger zone */
336
+ .profile-danger { border-color:rgba(251,113,133,.22); background:linear-gradient(180deg,rgba(251,113,133,.05) 0%,#111c33 100%); }
337
+ .profile-danger h3 { color:#fb7185; }
338
+ .profile-danger h3 svg { color:#fb7185; }
339
+ .btn-logout-danger {
340
+ display:inline-flex; align-items:center; gap:8px;
341
+ padding:9px 20px; background:rgba(251,113,133,.1);
342
+ border:1px solid rgba(251,113,133,.28); color:#fb7185;
343
+ border-radius:10px; font-weight:700; font-family:inherit;
344
+ font-size:.88rem; cursor:pointer; transition:all .2s;
345
+ }
346
+ .btn-logout-danger:hover { background:rgba(251,113,133,.18); border-color:#fb7185; transform:none; }
347
+
348
+ /* ── Responsive ─────────────────────────────────── */
349
+ @media(max-width:860px){
350
+ .auth-brand { display:none; }
351
+ .auth-form-panel { padding:40px 28px; background:#070d1a; }
352
+ }
353
+ @media(max-width:480px){
354
+ .auth-form-panel { padding:32px 18px; }
355
+ .auth-card-header h2 { font-size:1.45rem; }
356
+ }
static/css/base.css ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Shared user menu and profile modal styles */
2
+ /* ═══════════════════════════════════════════════════════════════
3
+ ICH Screening Dashboard β€” Stylesheet
4
+ ═══════════════════════════════════════════════════════════════ */
5
+
6
+ :root {
7
+ --bg: #070d1a;
8
+ --bg2: #0c1427;
9
+ --panel: #111c33;
10
+ --panel2: #162244;
11
+ --surface: #1a2850;
12
+ --text: #e8ecf6;
13
+ --muted: #8ba0c4;
14
+ --line: #243356;
15
+ --accent: #6ea8fe;
16
+ --green: #34d399;
17
+ --red: #fb7185;
18
+ --orange: #fbbf24;
19
+ --blue: #60a5fa;
20
+ --radius: 14px;
21
+ }
22
+
23
+ /* ── Reset ─────────────────────────────────────────────────── */
24
+ *,
25
+ *::before,
26
+ *::after {
27
+ box-sizing: border-box;
28
+ margin: 0;
29
+ padding: 0;
30
+ }
31
+ html {
32
+ scroll-behavior: smooth;
33
+ }
34
+ body {
35
+ font-family:
36
+ "Inter",
37
+ system-ui,
38
+ -apple-system,
39
+ "Segoe UI",
40
+ Roboto,
41
+ sans-serif;
42
+ background:
43
+ radial-gradient(
44
+ ellipse 1400px 500px at 5% -5%,
45
+ #1a2f55 0%,
46
+ transparent 60%
47
+ ),
48
+ radial-gradient(
49
+ ellipse 1200px 500px at 95% -5%,
50
+ #2a1d46 0%,
51
+ transparent 55%
52
+ ),
53
+ var(--bg);
54
+ color: var(--text);
55
+ line-height: 1.6;
56
+ min-height: 100vh;
57
+ }
58
+
59
+ /* ── Layout ────────────────────────────────────────────────── */
60
+ .container {
61
+ width: min(1240px, 94vw);
62
+ margin: 0 auto;
63
+ }
64
+ .page {
65
+ padding: 20px 0 48px;
66
+ }
67
+
68
+ /* ── Topbar ────────────────────────────────────────────────── */
69
+ .topbar {
70
+ position: sticky;
71
+ top: 0;
72
+ z-index: 50;
73
+ background: rgba(7, 13, 26, 0.88);
74
+ backdrop-filter: blur(12px);
75
+ border-bottom: 1px solid var(--line);
76
+ }
77
+ .topbar-inner {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ width: 100%;
82
+ padding: 14px 24px;
83
+ }
84
+ .brand {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 10px;
88
+ font-weight: 800;
89
+ font-size: 1.05rem;
90
+ color: var(--text);
91
+ text-decoration: none;
92
+ }
93
+ .brand-icon {
94
+ color: var(--accent);
95
+ display: flex;
96
+ }
97
+ .nav-links {
98
+ display: flex;
99
+ gap: 6px;
100
+ }
101
+ .nav-links a {
102
+ padding: 6px 14px;
103
+ border-radius: 8px;
104
+ color: var(--muted);
105
+ text-decoration: none;
106
+ font-weight: 500;
107
+ font-size: 0.9rem;
108
+ transition: all 0.15s;
109
+ }
110
+ .nav-links a:hover {
111
+ color: var(--text);
112
+ background: var(--panel);
113
+ }
114
+ .nav-links a.active {
115
+ color: var(--accent);
116
+ background: rgba(110, 168, 254, 0.1);
117
+ }
118
+
119
+ /* ── Hero ──────────────────────────────────────────────────── */
120
+ .hero {
121
+ padding: 8px 0 6px;
122
+ }
123
+ .hero h1 {
124
+ font-size: 1.8rem;
125
+ font-weight: 800;
126
+ }
127
+ .hero p {
128
+ color: var(--muted);
129
+ margin-top: 6px;
130
+ }
131
+
132
+ /* ── Stats row ─────────────────────────────────────────────── */
133
+ .stats-row {
134
+ display: grid;
135
+ grid-template-columns: repeat(6, 1fr);
136
+ gap: 12px;
137
+ margin: 16px 0;
138
+ }
139
+ .stat-card {
140
+ background: linear-gradient(180deg, var(--panel2), var(--panel));
141
+ border: 1px solid var(--line);
142
+ border-radius: var(--radius);
143
+ padding: 16px;
144
+ }
145
+ .stat-label {
146
+ font-size: 0.82rem;
147
+ color: var(--muted);
148
+ font-weight: 600;
149
+ text-transform: uppercase;
150
+ letter-spacing: 0.04em;
151
+ }
152
+ .stat-value {
153
+ font-size: 1.6rem;
154
+ font-weight: 800;
155
+ margin-top: 4px;
156
+ }
157
+ .stat-card.accent-green .stat-value {
158
+ color: var(--green);
159
+ }
160
+ .stat-card.accent-red .stat-value {
161
+ color: var(--red);
162
+ }
163
+ .stat-card.accent-orange .stat-value {
164
+ color: var(--orange);
165
+ }
166
+ .stat-card.accent-blue .stat-value {
167
+ color: var(--blue);
168
+ }
169
+
170
+ /* ── Info bar ──────────────────────────────────────────────── */
171
+ .info-bar {
172
+ display: flex;
173
+ gap: 24px;
174
+ flex-wrap: wrap;
175
+ padding: 10px 16px;
176
+ border-radius: 10px;
177
+ background: var(--panel);
178
+ border: 1px solid var(--line);
179
+ font-size: 0.88rem;
180
+ color: var(--muted);
181
+ margin-bottom: 12px;
182
+ }
183
+ .info-bar strong {
184
+ color: var(--text);
185
+ }
186
+
187
+ /* ── Panel ─────────────────────────────────────────────────── */
188
+ .panel {
189
+ background: linear-gradient(180deg, var(--panel2), var(--panel));
190
+ border: 1px solid var(--line);
191
+ border-radius: var(--radius);
192
+ padding: 20px;
193
+ margin-top: 16px;
194
+ }
195
+ .panel h3 {
196
+ font-size: 1rem;
197
+ font-weight: 700;
198
+ margin-bottom: 12px;
199
+ }
200
+
201
+ /* ── Filters ───────────────────────────────────────────────── */
202
+ .filters {
203
+ display: flex;
204
+ gap: 10px;
205
+ flex-wrap: wrap;
206
+ margin-bottom: 14px;
207
+ }
208
+ .filters input,
209
+ .filters select {
210
+ flex: 1;
211
+ min-width: 140px;
212
+ }
213
+ input,
214
+ select {
215
+ background: var(--bg2);
216
+ color: var(--text);
217
+ border: 1px solid var(--line);
218
+ border-radius: 10px;
219
+ padding: 10px 12px;
220
+ font-size: 0.9rem;
221
+ font-family: inherit;
222
+ transition: border-color 0.15s;
223
+ }
224
+ input:focus,
225
+ select:focus {
226
+ outline: none;
227
+ border-color: var(--accent);
228
+ }
229
+
static/css/components.css ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Buttons ───────────────────────────────────────────────── */
2
+ .btn,
3
+ button {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: 6px;
7
+ padding: 8px 16px;
8
+ border-radius: 10px;
9
+ border: 1px solid var(--line);
10
+ background: var(--panel);
11
+ color: var(--text);
12
+ font-size: 0.88rem;
13
+ font-weight: 500;
14
+ font-family: inherit;
15
+ cursor: pointer;
16
+ text-decoration: none;
17
+ transition: all 0.15s;
18
+ }
19
+ .btn:hover,
20
+ button:hover {
21
+ border-color: var(--accent);
22
+ background: var(--surface);
23
+ }
24
+ .btn-sm {
25
+ padding: 5px 12px;
26
+ font-size: 0.82rem;
27
+ }
28
+ .btn-ghost {
29
+ background: transparent;
30
+ }
31
+ .btn-outline {
32
+ background: transparent;
33
+ border-color: var(--line);
34
+ color: var(--muted);
35
+ }
36
+ .btn-outline:hover {
37
+ border-color: var(--accent);
38
+ color: var(--accent);
39
+ }
40
+
41
+ /* ── Table ─────────────────────────────────────────────────── */
42
+ .table-wrap {
43
+ overflow-x: auto;
44
+ }
45
+ table {
46
+ width: 100%;
47
+ border-collapse: collapse;
48
+ min-width: 940px;
49
+ }
50
+ th,
51
+ td {
52
+ padding: 10px 12px;
53
+ border-bottom: 1px solid var(--line);
54
+ text-align: left;
55
+ }
56
+ th {
57
+ color: var(--muted);
58
+ font-weight: 600;
59
+ font-size: 0.82rem;
60
+ text-transform: uppercase;
61
+ letter-spacing: 0.03em;
62
+ }
63
+ tr.row-positive {
64
+ background: rgba(251, 113, 133, 0.04);
65
+ }
66
+ a {
67
+ color: var(--accent);
68
+ transition: color 0.15s;
69
+ }
70
+ a:hover {
71
+ color: #9ec5ff;
72
+ }
73
+
74
+ .link-icon {
75
+ display: inline-flex;
76
+ }
77
+
78
+ /* ── Badges ────────────────────────────────────────────────── */
79
+ .badge {
80
+ display: inline-block;
81
+ padding: 3px 10px;
82
+ border-radius: 999px;
83
+ font-size: 0.78rem;
84
+ font-weight: 600;
85
+ letter-spacing: 0.03em;
86
+ border: 1px solid var(--line);
87
+ background: rgba(255, 255, 255, 0.04);
88
+ }
89
+ .badge-high {
90
+ border-color: #3b82f6;
91
+ color: #93bbfd;
92
+ }
93
+ .badge-medium {
94
+ border-color: #f59e0b;
95
+ color: #fcd34d;
96
+ }
97
+ .badge-low {
98
+ border-color: #6b7280;
99
+ color: #9ca3af;
100
+ }
101
+ .badge-urgent {
102
+ border-color: #ef4444;
103
+ color: #fca5a5;
104
+ background: rgba(239, 68, 68, 0.08);
105
+ }
106
+ .badge-standard {
107
+ border-color: #22c55e;
108
+ color: #86efac;
109
+ }
110
+
111
+ /* ── Dots ──────────────────────────────────────────────────── */
112
+ .dot {
113
+ display: inline-block;
114
+ width: 8px;
115
+ height: 8px;
116
+ border-radius: 50%;
117
+ margin-right: 6px;
118
+ vertical-align: middle;
119
+ }
120
+ .dot-green {
121
+ background: var(--green);
122
+ box-shadow: 0 0 8px var(--green);
123
+ }
124
+ .dot-red {
125
+ background: var(--red);
126
+ box-shadow: 0 0 8px var(--red);
127
+ }
128
+
129
+ /* ── Utility ───────────────────────────────────────────────── */
130
+ .mono {
131
+ font-family: "Consolas", "SF Mono", "Fira Code", monospace;
132
+ }
133
+ .muted {
134
+ color: var(--muted);
135
+ }
136
+ .small {
137
+ font-size: 0.85rem;
138
+ }
139
+
static/css/error_pages.css ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════
2
+ ICH Screening β€” Error Pages (404 / 500)
3
+ Standalone full-viewport animated pages
4
+ ═══════════════════════════════════════════════════ */
5
+
6
+ .error-page {
7
+ min-height: 100vh;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ justify-content: center;
12
+ background:
13
+ radial-gradient(ellipse 900px 600px at 50% 0%, rgba(110,168,254,.08) 0%, transparent 65%),
14
+ radial-gradient(ellipse 600px 500px at 80% 90%, rgba(99,102,241,.07) 0%, transparent 60%),
15
+ #070d1a;
16
+ font-family: "Inter", system-ui, -apple-system, sans-serif;
17
+ color: #e8ecf6;
18
+ text-align: center;
19
+ padding: 40px 24px;
20
+ position: relative;
21
+ overflow: hidden;
22
+ }
23
+
24
+ /* floating background orbs */
25
+ .error-orb {
26
+ position: absolute;
27
+ border-radius: 50%;
28
+ pointer-events: none;
29
+ animation: orb-drift linear infinite;
30
+ opacity: 0;
31
+ }
32
+ .error-orb:nth-child(1){
33
+ width:320px;height:320px;
34
+ background:radial-gradient(circle,rgba(110,168,254,.06),transparent 70%);
35
+ top:-80px;left:-80px; animation-duration:12s; animation-delay:0s;
36
+ }
37
+ .error-orb:nth-child(2){
38
+ width:240px;height:240px;
39
+ background:radial-gradient(circle,rgba(99,102,241,.08),transparent 70%);
40
+ bottom:-60px;right:-60px; animation-duration:15s; animation-delay:-4s;
41
+ }
42
+ .error-orb:nth-child(3){
43
+ width:180px;height:180px;
44
+ background:radial-gradient(circle,rgba(251,113,133,.05),transparent 70%);
45
+ top:50%;left:60%; animation-duration:10s; animation-delay:-7s;
46
+ }
47
+ @keyframes orb-drift {
48
+ 0% { opacity:0; transform:scale(.8) translate(0,0); }
49
+ 15% { opacity:1; }
50
+ 85% { opacity:1; }
51
+ 100% { opacity:0; transform:scale(1.1) translate(20px,20px); }
52
+ }
53
+
54
+ /* big number */
55
+ .error-code {
56
+ font-size: clamp(7rem, 18vw, 14rem);
57
+ font-weight: 900;
58
+ line-height: 1;
59
+ letter-spacing: -.05em;
60
+ position: relative;
61
+ z-index: 1;
62
+ background: linear-gradient(135deg, #6ea8fe 10%, #a78bfa 50%, #6366f1 90%);
63
+ -webkit-background-clip: text;
64
+ -webkit-text-fill-color: transparent;
65
+ background-clip: text;
66
+ filter: drop-shadow(0 0 48px rgba(110,168,254,.35));
67
+ animation: code-in 0.7s cubic-bezier(.16,1,.3,1) both;
68
+ }
69
+ @keyframes code-in {
70
+ from{ opacity:0; transform:scale(.85) translateY(20px); }
71
+ to { opacity:1; transform:scale(1) translateY(0); }
72
+ }
73
+
74
+ /* animated scan line inside code */
75
+ .error-code-wrap {
76
+ position: relative;
77
+ display: inline-block;
78
+ }
79
+ .error-scanline {
80
+ position: absolute;
81
+ left: 0; right: 0;
82
+ height: 3px;
83
+ background: linear-gradient(90deg, transparent, rgba(110,168,254,.6), transparent);
84
+ animation: scanline 2.5s linear infinite;
85
+ top: 50%;
86
+ }
87
+ @keyframes scanline {
88
+ from { transform:translateY(-80px); opacity:0; }
89
+ 10% { opacity:1; }
90
+ 90% { opacity:1; }
91
+ to { transform:translateY(80px); opacity:0; }
92
+ }
93
+
94
+ /* SVG illustration */
95
+ .error-illustration {
96
+ margin: -10px 0 28px;
97
+ position: relative; z-index: 1;
98
+ animation: float-err 4s ease-in-out infinite;
99
+ }
100
+ @keyframes float-err {
101
+ 0%,100%{ transform:translateY(0); }
102
+ 50% { transform:translateY(-10px); }
103
+ }
104
+
105
+ /* text */
106
+ .error-title {
107
+ font-size: 1.6rem; font-weight: 800;
108
+ letter-spacing: -.02em; margin-bottom: 12px;
109
+ position: relative; z-index: 1;
110
+ animation: fade-up .5s ease .2s both;
111
+ }
112
+ .error-desc {
113
+ font-size: 1rem; color: #8ba0c4;
114
+ max-width: 440px; line-height: 1.7;
115
+ margin: 0 auto 36px;
116
+ position: relative; z-index: 1;
117
+ animation: fade-up .5s ease .3s both;
118
+ }
119
+ @keyframes fade-up {
120
+ from{ opacity:0; transform:translateY(14px); }
121
+ to { opacity:1; transform:translateY(0); }
122
+ }
123
+
124
+ /* action buttons */
125
+ .error-actions {
126
+ display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;
127
+ position: relative; z-index: 1;
128
+ animation: fade-up .5s ease .4s both;
129
+ }
130
+ .btn-err-primary {
131
+ display: inline-flex; align-items: center; gap: 8px;
132
+ padding: 12px 28px;
133
+ background: linear-gradient(135deg,#6ea8fe,#6366f1);
134
+ color: #fff; text-decoration: none;
135
+ border-radius: 12px; font-weight: 700; font-size: .95rem;
136
+ font-family: inherit;
137
+ box-shadow: 0 4px 20px rgba(110,168,254,.35);
138
+ transition: opacity .2s, transform .15s, box-shadow .2s;
139
+ border: none; cursor: pointer;
140
+ }
141
+ .btn-err-primary:hover {
142
+ opacity: .9; transform: translateY(-2px);
143
+ box-shadow: 0 6px 26px rgba(110,168,254,.45);
144
+ color: #fff;
145
+ }
146
+ .btn-err-secondary {
147
+ display: inline-flex; align-items: center; gap: 8px;
148
+ padding: 12px 28px;
149
+ background: transparent;
150
+ border: 1px solid #243356; color: #8ba0c4;
151
+ text-decoration: none; border-radius: 12px;
152
+ font-weight: 600; font-size: .93rem;
153
+ font-family: inherit; cursor: pointer;
154
+ transition: all .2s;
155
+ }
156
+ .btn-err-secondary:hover {
157
+ border-color: #6ea8fe; color: #6ea8fe; background: rgba(110,168,254,.06);
158
+ }
159
+
160
+ /* error code badge */
161
+ .error-badge {
162
+ display: inline-flex; align-items: center; gap: 6px;
163
+ margin-bottom: 16px;
164
+ padding: 5px 14px;
165
+ border-radius: 999px; font-size: .78rem; font-weight: 700;
166
+ letter-spacing: .06em; text-transform: uppercase;
167
+ position: relative; z-index: 1;
168
+ animation: fade-up .4s ease .1s both;
169
+ }
170
+ .error-badge-404 {
171
+ background: rgba(110,168,254,.1);
172
+ border: 1px solid rgba(110,168,254,.25);
173
+ color: #6ea8fe;
174
+ }
175
+ .error-badge-500 {
176
+ background: rgba(251,113,133,.1);
177
+ border: 1px solid rgba(251,113,133,.25);
178
+ color: #fb7185;
179
+ }
180
+
181
+ /* footer brand link */
182
+ .error-footer {
183
+ position: absolute; bottom: 24px;
184
+ font-size: .8rem; color: #3d5482;
185
+ }
186
+ .error-footer a { color: #6ea8fe; text-decoration: none; }
187
+ .error-footer a:hover { text-decoration: underline; }
188
+
189
+ /* 500 specific tint */
190
+ .error-page-500 .error-code {
191
+ background: linear-gradient(135deg,#fb7185 10%,#f97316 60%,#fbbf24 100%);
192
+ -webkit-background-clip: text; background-clip: text;
193
+ filter: drop-shadow(0 0 48px rgba(251,113,133,.3));
194
+ }
195
+ .error-page-500 .error-scanline {
196
+ background: linear-gradient(90deg,transparent,rgba(251,113,133,.5),transparent);
197
+ }
static/css/home.css ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════
2
+ ICH Screening β€” Home / Dashboard Page Styles
3
+ ═══════════════════════════════════════════════════ */
4
+
5
+ /* ── Landing hero ─────────────────────────────────── */
6
+ .landing-hero {
7
+ text-align: center;
8
+ padding: 72px 0 48px;
9
+ position: relative;
10
+ }
11
+ .landing-badge {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ gap: 6px;
15
+ padding: 5px 14px;
16
+ border-radius: 999px;
17
+ font-size: .75rem;
18
+ font-weight: 700;
19
+ letter-spacing: .06em;
20
+ text-transform: uppercase;
21
+ background: rgba(110,168,254,.1);
22
+ border: 1px solid rgba(110,168,254,.25);
23
+ color: #6ea8fe;
24
+ margin-bottom: 20px;
25
+ animation: badge-pop .5s cubic-bezier(.16,1,.3,1) both;
26
+ }
27
+ @keyframes badge-pop {
28
+ from { opacity: 0; transform: scale(.85); }
29
+ to { opacity: 1; transform: scale(1); }
30
+ }
31
+ .badge-dot {
32
+ width: 6px;
33
+ height: 6px;
34
+ border-radius: 50%;
35
+ background: #6ea8fe;
36
+ animation: blink 1.8s ease-in-out infinite;
37
+ }
38
+ @keyframes blink {
39
+ 0%, 100% { opacity: 1; }
40
+ 50% { opacity: .3; }
41
+ }
42
+ .landing-hero h1 {
43
+ font-size: clamp(2.2rem, 5vw, 3.4rem);
44
+ font-weight: 900;
45
+ line-height: 1.12;
46
+ letter-spacing: -.04em;
47
+ margin-bottom: 18px;
48
+ animation: hero-in .6s cubic-bezier(.16,1,.3,1) .1s both;
49
+ }
50
+ @keyframes hero-in {
51
+ from { opacity: 0; transform: translateY(22px); }
52
+ to { opacity: 1; transform: translateY(0); }
53
+ }
54
+ .hero-grad {
55
+ background: linear-gradient(135deg, #6ea8fe 0%, #a78bfa 55%, #6366f1 100%);
56
+ -webkit-background-clip: text;
57
+ -webkit-text-fill-color: transparent;
58
+ background-clip: text;
59
+ }
60
+ .landing-hero p {
61
+ font-size: 1.1rem;
62
+ color: #8ba0c4;
63
+ max-width: 580px;
64
+ margin: 0 auto 36px;
65
+ line-height: 1.7;
66
+ animation: hero-in .6s cubic-bezier(.16,1,.3,1) .2s both;
67
+ }
68
+ .hero-cta-row {
69
+ display: flex;
70
+ gap: 12px;
71
+ justify-content: center;
72
+ flex-wrap: wrap;
73
+ animation: hero-in .6s cubic-bezier(.16,1,.3,1) .3s both;
74
+ }
75
+ .btn-hero-primary {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: 8px;
79
+ padding: 13px 30px;
80
+ background: linear-gradient(135deg, #6ea8fe, #6366f1);
81
+ color: #fff;
82
+ text-decoration: none;
83
+ border-radius: 14px;
84
+ font-weight: 700;
85
+ font-size: .96rem;
86
+ border: none;
87
+ box-shadow: 0 4px 22px rgba(110,168,254,.35);
88
+ transition: opacity .2s, transform .15s, box-shadow .2s;
89
+ cursor: pointer;
90
+ }
91
+ .btn-hero-primary:hover {
92
+ opacity: .9;
93
+ transform: translateY(-2px);
94
+ box-shadow: 0 8px 28px rgba(110,168,254,.45);
95
+ color: #fff;
96
+ }
97
+ .btn-hero-secondary {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ gap: 8px;
101
+ padding: 13px 28px;
102
+ background: transparent;
103
+ border: 1px solid #243356;
104
+ color: #8ba0c4;
105
+ text-decoration: none;
106
+ border-radius: 14px;
107
+ font-weight: 600;
108
+ font-size: .94rem;
109
+ cursor: pointer;
110
+ transition: all .2s;
111
+ }
112
+ .btn-hero-secondary:hover {
113
+ border-color: #6ea8fe;
114
+ color: #6ea8fe;
115
+ background: rgba(110,168,254,.06);
116
+ }
117
+
118
+ /* ── Stats grid ───────────────────────────────────── */
119
+ .stats-section {
120
+ display: grid;
121
+ grid-template-columns: repeat(6, 1fr);
122
+ gap: 12px;
123
+ margin: 48px 0 0;
124
+ }
125
+ .stat-card {
126
+ background: linear-gradient(180deg, #162244, #111c33);
127
+ border: 1px solid #243356;
128
+ border-radius: 14px;
129
+ padding: 18px 16px;
130
+ animation: card-in .5s cubic-bezier(.16,1,.3,1) both;
131
+ transition: transform .2s, box-shadow .2s;
132
+ }
133
+ .stat-card:hover {
134
+ transform: translateY(-3px);
135
+ box-shadow: 0 8px 24px rgba(0,0,0,.3);
136
+ }
137
+ @keyframes card-in {
138
+ from { opacity: 0; transform: translateY(14px); }
139
+ to { opacity: 1; transform: translateY(0); }
140
+ }
141
+ .stat-label {
142
+ font-size: .78rem;
143
+ color: #8ba0c4;
144
+ font-weight: 700;
145
+ text-transform: uppercase;
146
+ letter-spacing: .05em;
147
+ }
148
+ .stat-value {
149
+ font-size: 1.7rem;
150
+ font-weight: 900;
151
+ margin-top: 6px;
152
+ }
153
+ .stat-card.accent-red .stat-value { color: #fb7185; }
154
+ .stat-card.accent-green .stat-value { color: #34d399; }
155
+ .stat-card.accent-orange .stat-value { color: #fbbf24; }
156
+ .stat-card.accent-blue .stat-value { color: #6ea8fe; }
157
+
158
+ /* ── Section heading ──────────────────────────────── */
159
+ .section-heading {
160
+ margin: 52px 0 20px;
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 14px;
164
+ }
165
+ .section-heading h2 {
166
+ font-size: 1.15rem;
167
+ font-weight: 800;
168
+ color: #e8ecf6;
169
+ letter-spacing: -.02em;
170
+ }
171
+ .section-line {
172
+ flex: 1;
173
+ height: 1px;
174
+ background: #243356;
175
+ }
176
+
177
+ /* ── Main action cards ────────────────────────────── */
178
+ .action-cards {
179
+ display: grid;
180
+ grid-template-columns: 1fr 1fr;
181
+ gap: 18px;
182
+ }
183
+ .action-card {
184
+ display: block;
185
+ text-decoration: none;
186
+ background: linear-gradient(135deg, #162244 0%, #111c33 100%);
187
+ border: 1px solid #243356;
188
+ border-radius: 18px;
189
+ padding: 28px;
190
+ transition: transform .2s, box-shadow .2s, border-color .2s;
191
+ position: relative;
192
+ overflow: hidden;
193
+ animation: card-in .5s cubic-bezier(.16,1,.3,1) .1s both;
194
+ }
195
+ .action-card::before {
196
+ content: '';
197
+ position: absolute;
198
+ inset: 0;
199
+ background: radial-gradient(ellipse 300px 200px at 0% 0%, rgba(110,168,254,.07), transparent);
200
+ opacity: 0;
201
+ transition: opacity .3s;
202
+ }
203
+ .action-card:hover {
204
+ transform: translateY(-4px);
205
+ border-color: rgba(110,168,254,.4);
206
+ box-shadow: 0 12px 36px rgba(0,0,0,.35);
207
+ }
208
+ .action-card:hover::before { opacity: 1; }
209
+ .action-card-icon {
210
+ width: 52px;
211
+ height: 52px;
212
+ border-radius: 14px;
213
+ background: rgba(110,168,254,.1);
214
+ border: 1px solid rgba(110,168,254,.18);
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ color: #6ea8fe;
219
+ margin-bottom: 18px;
220
+ transition: background .2s, box-shadow .2s;
221
+ }
222
+ .action-card:hover .action-card-icon {
223
+ background: rgba(110,168,254,.16);
224
+ box-shadow: 0 0 18px rgba(110,168,254,.2);
225
+ }
226
+ .action-card h2 {
227
+ font-size: 1.1rem;
228
+ font-weight: 800;
229
+ color: #e8ecf6;
230
+ margin-bottom: 8px;
231
+ letter-spacing: -.02em;
232
+ }
233
+ .action-card p {
234
+ font-size: .88rem;
235
+ color: #8ba0c4;
236
+ line-height: 1.65;
237
+ margin-bottom: 16px;
238
+ }
239
+ .action-card-cta {
240
+ display: inline-flex;
241
+ align-items: center;
242
+ gap: 5px;
243
+ font-size: .84rem;
244
+ font-weight: 700;
245
+ color: #6ea8fe;
246
+ transition: gap .2s;
247
+ }
248
+ .action-card:hover .action-card-cta { gap: 9px; }
249
+
250
+ /* ── Mini cards ───────────────────────────────────── */
251
+ .mini-cards {
252
+ display: grid;
253
+ grid-template-columns: repeat(3, 1fr);
254
+ gap: 14px;
255
+ margin-top: 18px;
256
+ }
257
+ .mini-card {
258
+ display: block;
259
+ text-decoration: none;
260
+ background: #111c33;
261
+ border: 1px solid #243356;
262
+ border-radius: 14px;
263
+ padding: 20px;
264
+ transition: transform .2s, border-color .2s, box-shadow .2s;
265
+ animation: card-in .5s cubic-bezier(.16,1,.3,1) .2s both;
266
+ }
267
+ .mini-card:hover {
268
+ transform: translateY(-3px);
269
+ border-color: rgba(110,168,254,.3);
270
+ box-shadow: 0 8px 24px rgba(0,0,0,.25);
271
+ }
272
+ .mini-card-icon {
273
+ color: #6ea8fe;
274
+ margin-bottom: 12px;
275
+ }
276
+ .mini-card h3 {
277
+ font-size: .95rem;
278
+ font-weight: 700;
279
+ color: #e8ecf6;
280
+ margin-bottom: 4px;
281
+ }
282
+ .mini-card p {
283
+ font-size: .82rem;
284
+ color: #8ba0c4;
285
+ line-height: 1.5;
286
+ }
287
+
288
+ /* ── How it works ─────────────────────────────────── */
289
+ .how-section { margin: 52px 0 0; }
290
+ .how-steps {
291
+ display: grid;
292
+ grid-template-columns: repeat(4, 1fr);
293
+ gap: 14px;
294
+ margin-top: 18px;
295
+ }
296
+ .how-step {
297
+ background: #111c33;
298
+ border: 1px solid #243356;
299
+ border-radius: 14px;
300
+ padding: 22px;
301
+ animation: card-in .5s cubic-bezier(.16,1,.3,1) both;
302
+ }
303
+ .how-step:nth-child(1) { animation-delay: .05s; }
304
+ .how-step:nth-child(2) { animation-delay: .12s; }
305
+ .how-step:nth-child(3) { animation-delay: .19s; }
306
+ .how-step:nth-child(4) { animation-delay: .26s; }
307
+ .how-num {
308
+ width: 30px;
309
+ height: 30px;
310
+ border-radius: 50%;
311
+ background: linear-gradient(135deg, #6ea8fe, #6366f1);
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ font-size: .78rem;
316
+ font-weight: 900;
317
+ color: #fff;
318
+ margin-bottom: 14px;
319
+ box-shadow: 0 0 12px rgba(110,168,254,.35);
320
+ }
321
+ .how-step h4 {
322
+ font-size: .9rem;
323
+ font-weight: 700;
324
+ color: #e8ecf6;
325
+ margin-bottom: 6px;
326
+ }
327
+ .how-step p {
328
+ font-size: .8rem;
329
+ color: #8ba0c4;
330
+ line-height: 1.6;
331
+ }
332
+
333
+ /* ── Disclaimer ───────────────────────────────────── */
334
+ .disclaimer-box {
335
+ margin-top: 40px;
336
+ padding: 16px 22px;
337
+ border-radius: 14px;
338
+ background: rgba(251,191,36,.05);
339
+ border: 1px solid rgba(251,191,36,.18);
340
+ font-size: .88rem;
341
+ line-height: 1.65;
342
+ color: #8ba0c4;
343
+ display: flex;
344
+ align-items: flex-start;
345
+ gap: 12px;
346
+ }
347
+ .disclaimer-icon {
348
+ color: #fbbf24;
349
+ flex-shrink: 0;
350
+ margin-top: 1px;
351
+ }
352
+ .disclaimer-box strong { color: #fbbf24; }
353
+
354
+ /* ── Responsive ───────────────────────────────────── */
355
+ @media (max-width: 900px) {
356
+ .stats-section { grid-template-columns: repeat(3, 1fr); }
357
+ .how-steps { grid-template-columns: repeat(2, 1fr); }
358
+ }
359
+ @media (max-width: 640px) {
360
+ .action-cards { grid-template-columns: 1fr; }
361
+ .mini-cards { grid-template-columns: 1fr 1fr; }
362
+ .stats-section { grid-template-columns: repeat(2, 1fr); }
363
+ .how-steps { grid-template-columns: 1fr; }
364
+ }
static/css/pages.css ADDED
@@ -0,0 +1,1078 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Detail page ───────────────────────────────────────────── */
2
+ .breadcrumb {
3
+ padding: 8px 0;
4
+ font-size: 0.88rem;
5
+ color: var(--muted);
6
+ }
7
+ .breadcrumb a {
8
+ color: var(--accent);
9
+ text-decoration: none;
10
+ }
11
+ .sep {
12
+ margin: 0 8px;
13
+ opacity: 0.4;
14
+ }
15
+
16
+ .detail-header {
17
+ display: flex;
18
+ justify-content: space-between;
19
+ align-items: flex-start;
20
+ gap: 16px;
21
+ flex-wrap: wrap;
22
+ margin: 6px 0 10px;
23
+ }
24
+ .detail-header h1 {
25
+ font-size: 1.5rem;
26
+ }
27
+ .detail-actions {
28
+ display: flex;
29
+ gap: 8px;
30
+ }
31
+
32
+ .detail-grid {
33
+ display: grid;
34
+ grid-template-columns: 1fr 1fr;
35
+ gap: 16px;
36
+ margin-top: 8px;
37
+ }
38
+
39
+ .kv-group {
40
+ }
41
+ .kv {
42
+ display: flex;
43
+ justify-content: space-between;
44
+ align-items: center;
45
+ padding: 9px 0;
46
+ border-bottom: 1px solid rgba(36, 51, 86, 0.6);
47
+ font-size: 0.92rem;
48
+ gap: 12px;
49
+ }
50
+ .kv span {
51
+ color: var(--muted);
52
+ }
53
+
54
+ .heatmap-img {
55
+ width: 100%;
56
+ border-radius: 12px;
57
+ border: 1px solid var(--line);
58
+ }
59
+
60
+ .empty-state {
61
+ display: flex;
62
+ flex-direction: column;
63
+ align-items: center;
64
+ justify-content: center;
65
+ padding: 48px 16px;
66
+ gap: 12px;
67
+ }
68
+
69
+ /* Probability bar */
70
+ .prob-bar-wrap {
71
+ margin-top: 20px;
72
+ }
73
+ .prob-bar-label {
74
+ display: flex;
75
+ justify-content: space-between;
76
+ font-size: 0.78rem;
77
+ color: var(--muted);
78
+ margin-bottom: 4px;
79
+ }
80
+ .prob-bar {
81
+ position: relative;
82
+ height: 24px;
83
+ border-radius: 12px;
84
+ background: var(--bg2);
85
+ border: 1px solid var(--line);
86
+ overflow: visible;
87
+ }
88
+ .prob-fill {
89
+ height: 100%;
90
+ border-radius: 12px;
91
+ transition: width 0.4s;
92
+ }
93
+ .fill-high {
94
+ background: linear-gradient(90deg, #3b82f6, #6366f1);
95
+ }
96
+ .fill-medium {
97
+ background: linear-gradient(90deg, #f59e0b, #f97316);
98
+ }
99
+ .fill-low {
100
+ background: linear-gradient(90deg, #6b7280, #9ca3af);
101
+ }
102
+ .prob-marker {
103
+ position: absolute;
104
+ top: -22px;
105
+ transform: translateX(-50%);
106
+ font-size: 0.76rem;
107
+ font-weight: 700;
108
+ color: var(--text);
109
+ }
110
+
111
+ .json-pre {
112
+ background: #080e1d;
113
+ border: 1px solid var(--line);
114
+ border-radius: 12px;
115
+ padding: 16px;
116
+ overflow: auto;
117
+ max-height: 500px;
118
+ font-size: 0.82rem;
119
+ line-height: 1.5;
120
+ }
121
+
122
+ /* Disclaimer */
123
+ .disclaimer-box {
124
+ margin-top: 16px;
125
+ padding: 16px 20px;
126
+ border-radius: var(--radius);
127
+ background: rgba(251, 191, 36, 0.06);
128
+ border: 1px solid rgba(251, 191, 36, 0.2);
129
+ font-size: 0.9rem;
130
+ line-height: 1.6;
131
+ color: var(--muted);
132
+ }
133
+ .disclaimer-box strong {
134
+ color: var(--orange);
135
+ }
136
+
137
+ /* ── Evaluation page ───────────────────────────────────────── */
138
+ .eval-grid {
139
+ display: grid;
140
+ grid-template-columns: 1fr 1fr;
141
+ gap: 16px;
142
+ margin-top: 16px;
143
+ }
144
+
145
+ .metric-grid {
146
+ display: grid;
147
+ grid-template-columns: 1fr 1fr;
148
+ gap: 10px;
149
+ }
150
+ .metric-card {
151
+ background: var(--bg2);
152
+ border: 1px solid var(--line);
153
+ border-radius: 10px;
154
+ padding: 14px;
155
+ text-align: center;
156
+ }
157
+ .metric-label {
158
+ font-size: 0.78rem;
159
+ color: var(--muted);
160
+ font-weight: 600;
161
+ text-transform: uppercase;
162
+ }
163
+ .metric-value {
164
+ font-size: 1.3rem;
165
+ font-weight: 800;
166
+ margin-top: 2px;
167
+ color: var(--accent);
168
+ }
169
+
170
+ /* Band analysis */
171
+ .band-grid {
172
+ display: grid;
173
+ grid-template-columns: repeat(3, 1fr);
174
+ gap: 12px;
175
+ margin-top: 12px;
176
+ }
177
+ .band-card {
178
+ background: var(--bg2);
179
+ border: 1px solid var(--line);
180
+ border-radius: 12px;
181
+ padding: 14px;
182
+ }
183
+ .band-header {
184
+ display: flex;
185
+ align-items: center;
186
+ gap: 10px;
187
+ margin-bottom: 12px;
188
+ }
189
+ .band-total {
190
+ color: var(--muted);
191
+ font-size: 0.85rem;
192
+ }
193
+ .band-bar-row {
194
+ display: flex;
195
+ align-items: center;
196
+ gap: 8px;
197
+ margin-bottom: 6px;
198
+ }
199
+ .band-bar-label {
200
+ width: 60px;
201
+ font-size: 0.8rem;
202
+ color: var(--muted);
203
+ }
204
+ .band-bar {
205
+ flex: 1;
206
+ height: 14px;
207
+ border-radius: 7px;
208
+ background: var(--panel);
209
+ overflow: hidden;
210
+ }
211
+ .band-bar-fill {
212
+ height: 100%;
213
+ border-radius: 7px;
214
+ transition: width 0.4s;
215
+ }
216
+ .fill-red {
217
+ background: var(--red);
218
+ }
219
+ .fill-green {
220
+ background: var(--green);
221
+ }
222
+ .band-bar-val {
223
+ width: 36px;
224
+ font-size: 0.82rem;
225
+ text-align: right;
226
+ }
227
+
228
+ /* Histogram */
229
+ .histogram {
230
+ display: flex;
231
+ align-items: flex-end;
232
+ gap: 6px;
233
+ margin-top: 12px;
234
+ padding: 8px 0;
235
+ min-height: 220px;
236
+ }
237
+ .hist-col {
238
+ flex: 1;
239
+ display: flex;
240
+ flex-direction: column;
241
+ align-items: center;
242
+ }
243
+ .hist-bar {
244
+ width: 100%;
245
+ border-radius: 6px 6px 0 0;
246
+ background: linear-gradient(180deg, var(--accent), #3b82f6);
247
+ min-height: 2px;
248
+ position: relative;
249
+ }
250
+ .hist-count {
251
+ position: absolute;
252
+ top: -20px;
253
+ left: 50%;
254
+ transform: translateX(-50%);
255
+ font-size: 0.72rem;
256
+ font-weight: 600;
257
+ color: var(--muted);
258
+ }
259
+ .hist-label {
260
+ font-size: 0.72rem;
261
+ color: var(--muted);
262
+ margin-top: 4px;
263
+ }
264
+
265
+ /* ── About page ─────────────────────────────────��──────────── */
266
+ .about-grid {
267
+ display: grid;
268
+ grid-template-columns: 1fr 1fr;
269
+ gap: 16px;
270
+ margin-top: 16px;
271
+ }
272
+
273
+ /* Architecture flow */
274
+ .arch-flow {
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 6px;
278
+ flex-wrap: wrap;
279
+ margin-top: 16px;
280
+ padding: 12px 0;
281
+ }
282
+ .arch-step {
283
+ display: flex;
284
+ align-items: center;
285
+ gap: 8px;
286
+ background: var(--bg2);
287
+ border: 1px solid var(--line);
288
+ border-radius: 10px;
289
+ padding: 10px 14px;
290
+ }
291
+ .arch-num {
292
+ width: 26px;
293
+ height: 26px;
294
+ border-radius: 50%;
295
+ background: var(--accent);
296
+ color: var(--bg);
297
+ font-weight: 800;
298
+ font-size: 0.82rem;
299
+ display: flex;
300
+ align-items: center;
301
+ justify-content: center;
302
+ }
303
+ .arch-label {
304
+ font-size: 0.85rem;
305
+ font-weight: 500;
306
+ }
307
+ .arch-arrow {
308
+ color: var(--muted);
309
+ font-size: 1.2rem;
310
+ }
311
+
312
+ /* Triage cards */
313
+ .triage-grid {
314
+ display: grid;
315
+ grid-template-columns: repeat(3, 1fr);
316
+ gap: 12px;
317
+ margin-top: 12px;
318
+ }
319
+ .triage-card {
320
+ background: var(--bg2);
321
+ border: 1px solid var(--line);
322
+ border-radius: 12px;
323
+ padding: 16px;
324
+ }
325
+ .triage-card p {
326
+ font-size: 0.88rem;
327
+ margin-top: 6px;
328
+ color: var(--muted);
329
+ }
330
+ .triage-card p strong {
331
+ color: var(--text);
332
+ }
333
+ .triage-header {
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 10px;
337
+ margin-bottom: 8px;
338
+ font-size: 0.85rem;
339
+ color: var(--muted);
340
+ }
341
+
342
+ /* Ethics */
343
+ .ethics-columns {
344
+ display: grid;
345
+ grid-template-columns: 1fr 1fr;
346
+ gap: 24px;
347
+ margin-top: 12px;
348
+ }
349
+ .ethics-columns h4 {
350
+ font-size: 0.95rem;
351
+ margin-bottom: 8px;
352
+ }
353
+ .check-list,
354
+ .cross-list {
355
+ list-style: none;
356
+ padding: 0;
357
+ }
358
+ .check-list li,
359
+ .cross-list li {
360
+ padding: 5px 0;
361
+ padding-left: 24px;
362
+ position: relative;
363
+ font-size: 0.9rem;
364
+ color: var(--muted);
365
+ }
366
+ .check-list li::before {
367
+ content: "βœ“";
368
+ position: absolute;
369
+ left: 0;
370
+ color: var(--green);
371
+ font-weight: 700;
372
+ }
373
+ .cross-list li::before {
374
+ content: "βœ•";
375
+ position: absolute;
376
+ left: 0;
377
+ color: var(--red);
378
+ font-weight: 700;
379
+ }
380
+
381
+ /* Tech tags */
382
+ .tech-tags {
383
+ display: flex;
384
+ flex-wrap: wrap;
385
+ gap: 8px;
386
+ margin-top: 4px;
387
+ }
388
+ .tech-tag {
389
+ padding: 5px 14px;
390
+ border-radius: 999px;
391
+ font-size: 0.82rem;
392
+ font-weight: 500;
393
+ background: rgba(110, 168, 254, 0.08);
394
+ border: 1px solid rgba(110, 168, 254, 0.2);
395
+ color: var(--accent);
396
+ }
397
+
398
+ /* ── Footer ────────────────────────────────────────────────── */
399
+ .footer {
400
+ margin-top: 48px;
401
+ padding: 20px 0;
402
+ border-top: 1px solid var(--line);
403
+ text-align: center;
404
+ font-size: 0.85rem;
405
+ color: var(--muted);
406
+ }
407
+ .footer p + p {
408
+ margin-top: 4px;
409
+ }
410
+
411
+ /* ── Home Page ─────────────────────────────────────────────── */
412
+ .home-hero {
413
+ text-align: center;
414
+ padding: 48px 0 12px;
415
+ }
416
+ .home-hero h1 {
417
+ font-size: 2.2rem;
418
+ font-weight: 800;
419
+ }
420
+ .home-hero p {
421
+ color: var(--muted);
422
+ margin-top: 8px;
423
+ max-width: 600px;
424
+ margin-left: auto;
425
+ margin-right: auto;
426
+ }
427
+
428
+ .home-cards {
429
+ display: grid;
430
+ grid-template-columns: 1fr 1fr;
431
+ gap: 20px;
432
+ margin-top: 32px;
433
+ }
434
+
435
+ .home-card {
436
+ display: flex;
437
+ flex-direction: column;
438
+ align-items: center;
439
+ text-align: center;
440
+ padding: 40px 32px;
441
+ border-radius: var(--radius);
442
+ border: 1px solid var(--line);
443
+ background: linear-gradient(180deg, var(--panel2), var(--panel));
444
+ text-decoration: none;
445
+ color: var(--text);
446
+ transition: all 0.2s;
447
+ }
448
+ .home-card:hover {
449
+ border-color: var(--accent);
450
+ transform: translateY(-2px);
451
+ box-shadow: 0 8px 32px rgba(110, 168, 254, 0.1);
452
+ }
453
+ .home-card-icon {
454
+ color: var(--accent);
455
+ margin-bottom: 16px;
456
+ }
457
+ .home-card h2 {
458
+ font-size: 1.3rem;
459
+ font-weight: 700;
460
+ margin-bottom: 8px;
461
+ }
462
+ .home-card p {
463
+ color: var(--muted);
464
+ font-size: 0.92rem;
465
+ line-height: 1.5;
466
+ }
467
+ .home-card-action {
468
+ margin-top: 16px;
469
+ color: var(--accent);
470
+ font-weight: 600;
471
+ font-size: 0.9rem;
472
+ }
473
+
474
+ .home-cards-secondary {
475
+ grid-template-columns: repeat(3, 1fr);
476
+ margin-top: 16px;
477
+ }
478
+ .home-card-sm {
479
+ padding: 28px 24px;
480
+ }
481
+ .home-card-sm h3 {
482
+ font-size: 1.05rem;
483
+ font-weight: 700;
484
+ margin-bottom: 4px;
485
+ }
486
+
487
+ /* ── Page Header ───────────────────────────────────────────── */
488
+ .page-header {
489
+ margin-bottom: 24px;
490
+ }
491
+ .page-header h1 {
492
+ font-size: 1.8rem;
493
+ font-weight: 800;
494
+ }
495
+ .page-header p {
496
+ color: var(--muted);
497
+ margin-top: 6px;
498
+ line-height: 1.5;
499
+ }
500
+
501
+ /* ── Logs ───────────────────────────────────────────────────── */
502
+ .log-summary {
503
+ margin-bottom: 12px;
504
+ }
505
+ .logs-table td code {
506
+ font-family: "SF Mono", "Cascadia Code", monospace;
507
+ font-size: 0.85rem;
508
+ color: var(--accent);
509
+ }
510
+ .log-actions {
511
+ display: flex;
512
+ gap: 6px;
513
+ }
514
+
515
+ /* ── Upload Page ───────────────────────────────────────────── */
516
+ .upload-hero {
517
+ padding: 8px 0 6px;
518
+ }
519
+ .upload-hero h1 {
520
+ font-size: 1.8rem;
521
+ font-weight: 800;
522
+ }
523
+ .upload-hero p {
524
+ color: var(--muted);
525
+ margin-top: 6px;
526
+ }
527
+
528
+ .upload-panel {
529
+ position: relative;
530
+ }
531
+
532
+ .dropzone {
533
+ display: flex;
534
+ flex-direction: column;
535
+ align-items: center;
536
+ justify-content: center;
537
+ padding: 48px 24px;
538
+ border: 2px dashed var(--line);
539
+ border-radius: 12px;
540
+ cursor: pointer;
541
+ transition: all 0.2s;
542
+ color: var(--muted);
543
+ }
544
+ .dropzone:hover,
545
+ .dropzone.dragover {
546
+ border-color: var(--accent);
547
+ background: rgba(110, 168, 254, 0.04);
548
+ }
549
+ .dropzone-text {
550
+ font-size: 1.05rem;
551
+ font-weight: 600;
552
+ margin-top: 12px;
553
+ color: var(--text);
554
+ }
555
+
556
+ .file-info {
557
+ display: flex;
558
+ align-items: center;
559
+ gap: 10px;
560
+ padding: 14px 16px;
561
+ border: 1px solid var(--accent);
562
+ border-radius: 10px;
563
+ background: rgba(110, 168, 254, 0.06);
564
+ color: var(--accent);
565
+ font-weight: 500;
566
+ }
567
+ .file-info span {
568
+ flex: 1;
569
+ }
570
+
571
+ .btn-primary {
572
+ margin-top: 16px;
573
+ width: 100%;
574
+ justify-content: center;
575
+ padding: 12px 24px;
576
+ background: linear-gradient(135deg, #3b82f6, #6366f1);
577
+ border-color: #3b82f6;
578
+ font-weight: 600;
579
+ font-size: 0.95rem;
580
+ }
581
+ .btn-primary:hover {
582
+ background: linear-gradient(135deg, #2563eb, #4f46e5);
583
+ }
584
+ .btn-primary:disabled {
585
+ opacity: 0.4;
586
+ cursor: not-allowed;
587
+ }
588
+
589
+ .loading-overlay {
590
+ position: absolute;
591
+ inset: 0;
592
+ display: flex;
593
+ flex-direction: column;
594
+ align-items: center;
595
+ justify-content: center;
596
+ background: rgba(17, 28, 51, 0.95);
597
+ border-radius: var(--radius);
598
+ z-index: 10;
599
+ gap: 16px;
600
+ }
601
+
602
+ .spinner {
603
+ width: 48px;
604
+ height: 48px;
605
+ border: 3px solid var(--line);
606
+ border-top-color: var(--accent);
607
+ border-radius: 50%;
608
+ animation: spin 0.8s linear infinite;
609
+ }
610
+ @keyframes spin {
611
+ to {
612
+ transform: rotate(360deg);
613
+ }
614
+ }
615
+
616
+ .user-menu {
617
+ position: relative;
618
+ display: flex;
619
+ align-items: center;
620
+ }
621
+
622
+ .user-button {
623
+ display: flex;
624
+ align-items: center;
625
+ gap: 8px;
626
+ padding: 8px 16px;
627
+ background: none;
628
+ border: 1px solid #e5e7eb;
629
+ border-radius: 6px;
630
+ color: #374151;
631
+ font-size: 14px;
632
+ font-weight: 500;
633
+ cursor: pointer;
634
+ transition: all 0.2s;
635
+ }
636
+
637
+ .user-button:hover {
638
+ background-color: #f9fafb;
639
+ border-color: #d1d5db;
640
+ }
641
+
642
+ .user-menu-dropdown {
643
+ display: none;
644
+ position: absolute;
645
+ top: 100%;
646
+ right: 0;
647
+ margin-top: 8px;
648
+ background: white;
649
+ border: 1px solid #e5e7eb;
650
+ border-radius: 6px;
651
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
652
+ min-width: 160px;
653
+ z-index: 1000;
654
+ }
655
+
656
+ .user-menu-dropdown.active {
657
+ display: block;
658
+ }
659
+
660
+ .menu-item {
661
+ display: block;
662
+ width: 100%;
663
+ padding: 12px 16px;
664
+ text-align: left;
665
+ color: #374151;
666
+ text-decoration: none;
667
+ font-size: 14px;
668
+ transition: all 0.2s;
669
+ border: none;
670
+ background: none;
671
+ cursor: pointer;
672
+ }
673
+
674
+ .menu-item:hover {
675
+ background-color: #f3f4f6;
676
+ color: #111827;
677
+ }
678
+
679
+ .menu-item:first-child {
680
+ border-radius: 5px 5px 0 0;
681
+ }
682
+
683
+ .logout-btn {
684
+ color: #dc2626;
685
+ }
686
+
687
+ .logout-btn:hover {
688
+ background-color: #fef2f2;
689
+ color: #991b1b;
690
+ }
691
+
692
+ .logout-form {
693
+ width: 100%;
694
+ margin: 0;
695
+ }
696
+
697
+ .user-menu-dropdown hr {
698
+ margin: 4px 0;
699
+ border: none;
700
+ border-top: 1px solid #e5e7eb;
701
+ }
702
+
703
+ .auth-buttons {
704
+ display: flex;
705
+ gap: 10px;
706
+ align-items: center;
707
+ }
708
+
709
+ .profile-container {
710
+ display: flex;
711
+ justify-content: center;
712
+ padding: 40px 20px;
713
+ max-width: 600px;
714
+ margin: 0 auto;
715
+ }
716
+
717
+ .profile-card {
718
+ background: white;
719
+ border-radius: 8px;
720
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
721
+ padding: 40px;
722
+ width: 100%;
723
+ }
724
+
725
+ .profile-card h1 {
726
+ margin-bottom: 30px;
727
+ color: #333;
728
+ }
729
+
730
+ .profile-section {
731
+ margin-bottom: 30px;
732
+ padding-bottom: 20px;
733
+ border-bottom: 1px solid #eee;
734
+ }
735
+
736
+ .profile-section h3 {
737
+ color: #333;
738
+ margin-bottom: 15px;
739
+ }
740
+
741
+ .profile-item {
742
+ display: flex;
743
+ justify-content: space-between;
744
+ padding: 12px 0;
745
+ border-bottom: 1px solid #f5f5f5;
746
+ }
747
+
748
+ .profile-label {
749
+ font-weight: 500;
750
+ color: #666;
751
+ }
752
+
753
+ .profile-value {
754
+ color: #333;
755
+ }
756
+
757
+ .profile-footer {
758
+ margin-top: 30px;
759
+ display: flex;
760
+ gap: 10px;
761
+ }
762
+
763
+ .modal {
764
+ display: none;
765
+ position: fixed;
766
+ z-index: 1000;
767
+ left: 0;
768
+ top: 0;
769
+ width: 100%;
770
+ height: 100%;
771
+ background-color: rgba(0, 0, 0, 0.4);
772
+ }
773
+
774
+ .modal-content {
775
+ background-color: white;
776
+ margin: 10% auto;
777
+ padding: 30px;
778
+ border-radius: 8px;
779
+ width: 90%;
780
+ max-width: 400px;
781
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
782
+ }
783
+
784
+ .close {
785
+ color: #aaa;
786
+ float: right;
787
+ font-size: 28px;
788
+ font-weight: bold;
789
+ cursor: pointer;
790
+ background: none;
791
+ border: none;
792
+ padding: 0;
793
+ }
794
+
795
+ .close:hover {
796
+ color: #000;
797
+ }
798
+
799
+ .form-group {
800
+ display: flex;
801
+ flex-direction: column;
802
+ margin-bottom: 15px;
803
+ }
804
+
805
+ .form-group label {
806
+ margin-bottom: 8px;
807
+ font-weight: 500;
808
+ color: #333;
809
+ }
810
+
811
+ .form-group small {
812
+ font-size: 12px;
813
+ color: #666;
814
+ margin-top: 4px;
815
+ }
816
+
817
+ @media (max-width: 768px) {
818
+ .user-button {
819
+ padding: 8px 12px;
820
+ font-size: 13px;
821
+ }
822
+
823
+ .user-button svg {
824
+ width: 18px;
825
+ height: 18px;
826
+ }
827
+
828
+ .auth-buttons {
829
+ gap: 8px;
830
+ }
831
+
832
+ .profile-container {
833
+ padding: 24px 16px;
834
+ }
835
+
836
+ .profile-card {
837
+ padding: 24px 18px;
838
+ }
839
+
840
+ .profile-item {
841
+ flex-direction: column;
842
+ gap: 4px;
843
+ }
844
+ }
845
+ .steps-grid {
846
+ display: grid;
847
+ grid-template-columns: repeat(4, 1fr);
848
+ gap: 16px;
849
+ margin-top: 12px;
850
+ }
851
+ .step {
852
+ display: flex;
853
+ align-items: flex-start;
854
+ gap: 12px;
855
+ }
856
+ .step-num {
857
+ width: 28px;
858
+ height: 28px;
859
+ border-radius: 50%;
860
+ background: var(--accent);
861
+ color: var(--bg);
862
+ font-weight: 800;
863
+ font-size: 0.82rem;
864
+ display: flex;
865
+ align-items: center;
866
+ justify-content: center;
867
+ flex-shrink: 0;
868
+ }
869
+ .step-text strong {
870
+ font-size: 0.92rem;
871
+ }
872
+
873
+ /* ── Flash Messages ────────────────────────────────────────── */
874
+ .flash-messages {
875
+ margin-bottom: 16px;
876
+ }
877
+ .flash {
878
+ padding: 12px 16px;
879
+ border-radius: 10px;
880
+ font-size: 0.9rem;
881
+ margin-bottom: 8px;
882
+ }
883
+ .flash-error {
884
+ background: rgba(251, 113, 133, 0.1);
885
+ border: 1px solid rgba(251, 113, 133, 0.3);
886
+ color: var(--red);
887
+ }
888
+ .flash-success {
889
+ background: rgba(52, 211, 153, 0.1);
890
+ border: 1px solid rgba(52, 211, 153, 0.3);
891
+ color: var(--green);
892
+ }
893
+
894
+ /* ── Upload Tabs ───────────────────────────────────────────── */
895
+ .upload-tabs {
896
+ display: flex;
897
+ gap: 4px;
898
+ margin-bottom: 0;
899
+ border-bottom: 2px solid var(--line);
900
+ padding-bottom: 0;
901
+ }
902
+ .upload-tab {
903
+ padding: 10px 20px;
904
+ background: none;
905
+ border: none;
906
+ color: var(--muted);
907
+ font-size: 0.9rem;
908
+ font-weight: 600;
909
+ font-family: inherit;
910
+ cursor: pointer;
911
+ border-bottom: 2px solid transparent;
912
+ margin-bottom: -2px;
913
+ transition: all 0.15s;
914
+ }
915
+ .upload-tab:hover {
916
+ color: var(--text);
917
+ }
918
+ .upload-tab.active {
919
+ color: var(--accent);
920
+ border-bottom-color: var(--accent);
921
+ }
922
+ .tab-panel {
923
+ display: none;
924
+ margin-top: 16px;
925
+ }
926
+ .tab-panel.active {
927
+ display: block;
928
+ }
929
+
930
+ /* ── Directory Input ───────────────────────────────────────── */
931
+ .dir-label {
932
+ display: block;
933
+ font-weight: 600;
934
+ margin-bottom: 8px;
935
+ font-size: 0.92rem;
936
+ }
937
+ .dir-input-row {
938
+ display: flex;
939
+ gap: 10px;
940
+ }
941
+ .dir-input-row .input {
942
+ flex: 1;
943
+ padding: 10px 14px;
944
+ font-size: 0.92rem;
945
+ font-family: "SF Mono", "Cascadia Code", monospace;
946
+ background: var(--panel);
947
+ border: 1px solid var(--line);
948
+ border-radius: var(--radius);
949
+ color: var(--text);
950
+ outline: none;
951
+ transition: border-color 0.15s;
952
+ }
953
+ .dir-input-row .input:focus {
954
+ border-color: var(--accent);
955
+ }
956
+ .dir-input-row .btn-primary {
957
+ margin-top: 0;
958
+ width: auto;
959
+ white-space: nowrap;
960
+ }
961
+
962
+ /* ── Batch Progress Page ───────────────────────────────────── */
963
+ .batch-header {
964
+ margin-bottom: 20px;
965
+ }
966
+ .batch-header h1 {
967
+ font-size: 1.6rem;
968
+ font-weight: 800;
969
+ }
970
+ .batch-panel {
971
+ padding: 24px;
972
+ }
973
+ .batch-stats-row {
974
+ display: grid;
975
+ grid-template-columns: repeat(4, 1fr);
976
+ gap: 12px;
977
+ margin-bottom: 20px;
978
+ }
979
+ .batch-stat {
980
+ text-align: center;
981
+ }
982
+ .batch-stat-label {
983
+ display: block;
984
+ font-size: 0.78rem;
985
+ color: var(--muted);
986
+ font-weight: 600;
987
+ text-transform: uppercase;
988
+ letter-spacing: 0.04em;
989
+ }
990
+ .batch-stat-value {
991
+ display: block;
992
+ font-size: 1.6rem;
993
+ font-weight: 800;
994
+ margin-top: 2px;
995
+ }
996
+ .batch-stat.accent-green .batch-stat-value {
997
+ color: var(--green);
998
+ }
999
+ .batch-stat.accent-red .batch-stat-value {
1000
+ color: var(--red);
1001
+ }
1002
+
1003
+ .progress-track {
1004
+ width: 100%;
1005
+ height: 12px;
1006
+ background: var(--panel);
1007
+ border: 1px solid var(--line);
1008
+ border-radius: 6px;
1009
+ overflow: hidden;
1010
+ }
1011
+ .progress-fill {
1012
+ height: 100%;
1013
+ background: linear-gradient(90deg, #3b82f6, #6366f1);
1014
+ border-radius: 6px;
1015
+ transition: width 0.4s ease;
1016
+ }
1017
+ .progress-text {
1018
+ display: flex;
1019
+ justify-content: space-between;
1020
+ margin-top: 8px;
1021
+ font-size: 0.88rem;
1022
+ font-weight: 600;
1023
+ }
1024
+
1025
+ .batch-feed {
1026
+ list-style: none;
1027
+ padding: 0;
1028
+ margin: 0;
1029
+ }
1030
+ .batch-feed li {
1031
+ padding: 6px 0;
1032
+ border-bottom: 1px solid var(--line);
1033
+ font-size: 0.88rem;
1034
+ }
1035
+ .batch-feed li a {
1036
+ color: var(--accent);
1037
+ text-decoration: none;
1038
+ }
1039
+ .batch-feed li a:hover {
1040
+ text-decoration: underline;
1041
+ }
1042
+
1043
+ .batch-done-panel {
1044
+ text-align: center;
1045
+ padding: 40px 24px;
1046
+ }
1047
+ .batch-done-icon {
1048
+ margin-bottom: 16px;
1049
+ }
1050
+ .batch-done-panel h2 {
1051
+ font-size: 1.5rem;
1052
+ font-weight: 800;
1053
+ margin-bottom: 8px;
1054
+ }
1055
+ .batch-done-actions {
1056
+ display: flex;
1057
+ gap: 12px;
1058
+ justify-content: center;
1059
+ margin-top: 20px;
1060
+ }
1061
+ .batch-done-actions .btn-primary {
1062
+ width: auto;
1063
+ margin-top: 0;
1064
+ }
1065
+
1066
+ .batch-fail-list {
1067
+ padding-left: 20px;
1068
+ }
1069
+ .batch-fail-list li {
1070
+ color: var(--red);
1071
+ font-family: "SF Mono", "Cascadia Code", monospace;
1072
+ font-size: 0.85rem;
1073
+ padding: 3px 0;
1074
+ }
1075
+ .text-red {
1076
+ color: var(--red);
1077
+ }
1078
+
static/css/responsive.css ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Responsive ────────────────────────────────────────────── */
2
+ @media (max-width: 1024px) {
3
+ .stats-row {
4
+ grid-template-columns: repeat(3, 1fr);
5
+ }
6
+ .home-cards-secondary {
7
+ grid-template-columns: 1fr;
8
+ }
9
+ .topbar-inner {
10
+ padding: 14px 16px;
11
+ }
12
+ .detail-grid,
13
+ .eval-grid,
14
+ .about-grid,
15
+ .ethics-columns {
16
+ grid-template-columns: 1fr;
17
+ }
18
+ .triage-grid,
19
+ .band-grid {
20
+ grid-template-columns: 1fr;
21
+ }
22
+ .arch-flow {
23
+ justify-content: center;
24
+ }
25
+ .home-cards {
26
+ grid-template-columns: 1fr;
27
+ }
28
+ .steps-grid {
29
+ grid-template-columns: 1fr 1fr;
30
+ }
31
+ .batch-stats-row {
32
+ grid-template-columns: repeat(2, 1fr);
33
+ }
34
+ }
35
+ @media (max-width: 640px) {
36
+ .stats-row {
37
+ grid-template-columns: 1fr 1fr;
38
+ }
39
+ .topbar-inner {
40
+ flex-direction: column;
41
+ gap: 8px;
42
+ }
43
+ .nav-links {
44
+ width: 100%;
45
+ justify-content: center;
46
+ flex-wrap: wrap;
47
+ }
48
+ .detail-header {
49
+ flex-direction: column;
50
+ }
51
+ .filters {
52
+ flex-direction: column;
53
+ }
54
+ .home-hero h1 {
55
+ font-size: 1.7rem;
56
+ }
57
+ .home-card {
58
+ padding: 28px 20px;
59
+ }
60
+ .steps-grid {
61
+ grid-template-columns: 1fr;
62
+ }
63
+ .upload-tabs {
64
+ flex-wrap: wrap;
65
+ }
66
+ .dir-input-row {
67
+ flex-direction: column;
68
+ }
69
+ .dir-input-row .btn-primary {
70
+ width: 100%;
71
+ }
72
+ .batch-done-actions {
73
+ flex-direction: column;
74
+ }
75
+ }
static/js/auth-shared.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * auth-shared.js β€” Shared utility for all auth pages
3
+ * Provides: makePasswordToggle(), passwordStrengthMeter()
4
+ */
5
+
6
+ /**
7
+ * Wire up a show/hide password toggle button.
8
+ * @param {string} btnId - ID of the toggle button
9
+ * @param {string} inputId - ID of the password input
10
+ * @param {string} iconId - ID of the SVG element inside the button
11
+ */
12
+ function makePasswordToggle(btnId, inputId, iconId) {
13
+ const btn = document.getElementById(btnId);
14
+ const input = document.getElementById(inputId);
15
+ const icon = document.getElementById(iconId);
16
+ if (!btn || !input || !icon) return;
17
+
18
+ btn.addEventListener('click', function () {
19
+ const isHidden = input.type === 'password';
20
+ input.type = isHidden ? 'text' : 'password';
21
+ icon.innerHTML = isHidden
22
+ /* eye-off */
23
+ ? '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>' +
24
+ '<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>' +
25
+ '<line x1="1" y1="1" x2="23" y2="23"/>'
26
+ /* eye */
27
+ : '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>' +
28
+ '<circle cx="12" cy="12" r="3"/>';
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Wire up a live password-strength indicator.
34
+ * @param {string} inputId - ID of the password input
35
+ * @param {string} barId - ID of the strength fill div
36
+ * @param {string} textId - ID of the strength label span
37
+ */
38
+ function passwordStrengthMeter(inputId, barId, textId) {
39
+ const input = document.getElementById(inputId);
40
+ const bar = document.getElementById(barId);
41
+ const text = document.getElementById(textId);
42
+ if (!input || !bar || !text) return;
43
+
44
+ input.addEventListener('input', function () {
45
+ const v = this.value;
46
+ let score = 0;
47
+ if (v.length >= 8) score++;
48
+ if (/[A-Z]/.test(v)) score++;
49
+ if (/[a-z]/.test(v)) score++;
50
+ if (/[0-9]/.test(v)) score++;
51
+ if (/[^A-Za-z0-9]/.test(v)) score++;
52
+
53
+ const classes = ['', 'weak', 'fair', 'good', 'good', 'strong'];
54
+ const labels = ['', 'Weak', 'Fair', 'Good', 'Good', 'Strong'];
55
+ const cls = classes[score] || '';
56
+
57
+ bar.className = 'pw-strength-fill ' + cls;
58
+ text.className = 'pw-strength-text ' + cls;
59
+ text.textContent = v.length ? labels[score] : '';
60
+ });
61
+ }
static/js/batch.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function initBatchProgress() {
3
+ var page = document.querySelector('.batch-page');
4
+ if (!page) {
5
+ return;
6
+ }
7
+
8
+ var statusUrl = page.dataset.statusUrl;
9
+ var pollMs = 1000;
10
+
11
+ var title = document.getElementById('batchTitle');
12
+ var subtitle = document.getElementById('batchSubtitle');
13
+ var fill = document.getElementById('progressFill');
14
+ var pctLabel = document.getElementById('progressPct');
15
+ var currentFile = document.getElementById('currentFile');
16
+ var statTotal = document.getElementById('statTotal');
17
+ var statProc = document.getElementById('statProcessed');
18
+ var statOK = document.getElementById('statSucceeded');
19
+ var statFail = document.getElementById('statFailed');
20
+ var feedPanel = document.getElementById('feedPanel');
21
+ var feedList = document.getElementById('batchFeed');
22
+ var donePanel = document.getElementById('donePanel');
23
+ var doneSummary = document.getElementById('doneSummary');
24
+ var failPanel = document.getElementById('failPanel');
25
+ var failList = document.getElementById('failList');
26
+ var prevIds = [];
27
+
28
+ if (!statusUrl || !title || !subtitle || !fill || !pctLabel || !currentFile || !statTotal || !statProc || !statOK || !statFail || !feedPanel || !feedList || !donePanel || !doneSummary || !failPanel || !failList) {
29
+ return;
30
+ }
31
+
32
+ function poll() {
33
+ fetch(statusUrl)
34
+ .then(function (response) {
35
+ return response.json();
36
+ })
37
+ .then(function (data) {
38
+ var pct = data.total > 0 ? Math.round(data.processed / data.total * 100) : 0;
39
+
40
+ statTotal.textContent = data.total;
41
+ statProc.textContent = data.processed;
42
+ statOK.textContent = data.succeeded;
43
+ statFail.textContent = data.failed_count;
44
+
45
+ fill.style.width = pct + '%';
46
+ pctLabel.textContent = pct + '%';
47
+ currentFile.textContent = data.current_file ? 'Processing: ' + data.current_file : '';
48
+
49
+ if (data.image_ids && data.image_ids.length) {
50
+ feedPanel.style.display = 'block';
51
+ data.image_ids.forEach(function (imageId) {
52
+ if (prevIds.indexOf(imageId) === -1) {
53
+ prevIds.push(imageId);
54
+ var li = document.createElement('li');
55
+ var link = document.createElement('a');
56
+ link.href = '/case/' + imageId;
57
+ link.textContent = imageId;
58
+ li.appendChild(link);
59
+ feedList.insertBefore(li, feedList.firstChild);
60
+ while (feedList.children.length > 20) {
61
+ feedList.removeChild(feedList.lastChild);
62
+ }
63
+ }
64
+ });
65
+ }
66
+
67
+ if (data.status === 'completed' || data.status === 'failed') {
68
+ title.textContent = 'Batch Complete';
69
+ subtitle.textContent = '';
70
+ donePanel.style.display = 'block';
71
+ doneSummary.textContent = data.succeeded + ' of ' + data.total + ' files processed successfully' + (data.failed_count > 0 ? ', ' + data.failed_count + ' failed' : '') + '.';
72
+
73
+ if (data.failed_ids && data.failed_ids.length) {
74
+ failPanel.style.display = 'block';
75
+ data.failed_ids.forEach(function (failedId) {
76
+ var li = document.createElement('li');
77
+ li.textContent = failedId;
78
+ failList.appendChild(li);
79
+ });
80
+ }
81
+ return;
82
+ }
83
+
84
+ setTimeout(poll, pollMs);
85
+ })
86
+ .catch(function () {
87
+ setTimeout(poll, pollMs * 3);
88
+ });
89
+ }
90
+
91
+ poll();
92
+ }
93
+
94
+ if (document.readyState === 'loading') {
95
+ document.addEventListener('DOMContentLoaded', initBatchProgress);
96
+ } else {
97
+ initBatchProgress();
98
+ }
99
+ })();
static/js/forgot-password.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * forgot-password.js β€” Forgot password page interactions
3
+ */
4
+ document.addEventListener('DOMContentLoaded', function () {
5
+ // If redirected back with ?sent=1, show the success state
6
+ const params = new URLSearchParams(window.location.search);
7
+ if (params.get('sent') === '1') {
8
+ const form = document.getElementById('fpForm');
9
+ const footer = document.querySelector('.auth-footer');
10
+ const state = document.getElementById('successState');
11
+ if (form) form.style.display = 'none';
12
+ if (footer) footer.style.display = 'none';
13
+ if (state) state.style.display = 'block';
14
+ }
15
+ });
static/js/home.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * home.js β€” Dashboard home page scripts
3
+ * Count-up animation for stat cards
4
+ */
5
+ document.addEventListener('DOMContentLoaded', function () {
6
+ document.querySelectorAll('[data-count]').forEach(function (el) {
7
+ const target = parseInt(el.dataset.count, 10);
8
+ if (!target) return;
9
+
10
+ let current = 0;
11
+ const duration = 900; // ms
12
+ const step = target / (duration / 16);
13
+
14
+ const timer = setInterval(function () {
15
+ current = Math.min(current + step, target);
16
+ el.textContent = Math.floor(current);
17
+ if (current >= target) {
18
+ el.textContent = target;
19
+ clearInterval(timer);
20
+ }
21
+ }, 16);
22
+ });
23
+ });
static/js/layout.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function initUserMenu() {
3
+ var menu = document.querySelector('.user-menu');
4
+ var toggleButton = document.querySelector('[data-user-menu-toggle="true"]');
5
+ var dropdown = document.getElementById('userMenuDropdown');
6
+
7
+ if (!menu || !toggleButton || !dropdown) {
8
+ return;
9
+ }
10
+
11
+ toggleButton.addEventListener('click', function (event) {
12
+ event.preventDefault();
13
+ event.stopPropagation();
14
+ dropdown.classList.toggle('active');
15
+ });
16
+
17
+ document.addEventListener('click', function (event) {
18
+ if (!menu.contains(event.target)) {
19
+ dropdown.classList.remove('active');
20
+ }
21
+ });
22
+ }
23
+
24
+ if (document.readyState === 'loading') {
25
+ document.addEventListener('DOMContentLoaded', initUserMenu);
26
+ } else {
27
+ initUserMenu();
28
+ }
29
+ })();
static/js/login.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * login.js β€” Login page interactions
3
+ * Depends on: auth-shared.js
4
+ */
5
+ document.addEventListener('DOMContentLoaded', function () {
6
+ makePasswordToggle('togglePw', 'password', 'eyeIcon');
7
+ });
static/js/pages.js ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function initUserMenu() {
3
+ var menu = document.querySelector('.user-menu');
4
+ var toggleButton = document.querySelector('[data-user-menu-toggle="true"]');
5
+ var dropdown = document.getElementById('userMenuDropdown');
6
+
7
+ if (!menu || !toggleButton || !dropdown) {
8
+ return;
9
+ }
10
+
11
+ function closeMenu() {
12
+ dropdown.classList.remove('active');
13
+ }
14
+
15
+ function toggleMenu(event) {
16
+ if (event) {
17
+ event.preventDefault();
18
+ event.stopPropagation();
19
+ }
20
+ dropdown.classList.toggle('active');
21
+ }
22
+
23
+ toggleButton.addEventListener('click', toggleMenu);
24
+ document.addEventListener('click', function (event) {
25
+ if (!menu.contains(event.target)) {
26
+ closeMenu();
27
+ }
28
+ });
29
+ }
30
+
31
+ function initPasswordModal() {
32
+ var openButton = document.querySelector('.js-open-password-modal');
33
+ var closeButtons = document.querySelectorAll('.js-close-password-modal');
34
+ var modal = document.querySelector('.js-password-modal');
35
+ var form = document.getElementById('changePasswordForm');
36
+ var message = document.getElementById('passwordMessage');
37
+
38
+ if (!openButton || !closeButtons.length || !modal || !form || !message) {
39
+ return;
40
+ }
41
+
42
+ function openModal() {
43
+ modal.style.display = 'block';
44
+ form.reset();
45
+ message.innerHTML = '';
46
+ }
47
+
48
+ function closeModal() {
49
+ modal.style.display = 'none';
50
+ }
51
+
52
+ openButton.addEventListener('click', openModal);
53
+ closeButtons.forEach(function (button) {
54
+ button.addEventListener('click', closeModal);
55
+ });
56
+
57
+ document.addEventListener('click', function (event) {
58
+ if (event.target === modal) {
59
+ closeModal();
60
+ }
61
+ });
62
+
63
+ form.addEventListener('submit', async function (event) {
64
+ event.preventDefault();
65
+
66
+ var currentPassword = document.getElementById('currentPassword').value;
67
+ var newPassword = document.getElementById('newPassword').value;
68
+ var confirmPassword = document.getElementById('confirmPassword').value;
69
+ var endpoint = form.dataset.changePasswordUrl;
70
+
71
+ if (newPassword !== confirmPassword) {
72
+ message.innerHTML = '<div class="alert alert-error">Passwords do not match</div>';
73
+ return;
74
+ }
75
+
76
+ try {
77
+ var response = await fetch(endpoint, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json'
81
+ },
82
+ body: JSON.stringify({
83
+ current_password: currentPassword,
84
+ new_password: newPassword,
85
+ confirm_password: confirmPassword
86
+ })
87
+ });
88
+
89
+ var data = await response.json();
90
+
91
+ if (response.ok) {
92
+ message.innerHTML = '<div class="alert alert-success">' + data.message + '</div>';
93
+ setTimeout(closeModal, 2000);
94
+ } else {
95
+ message.innerHTML = '<div class="alert alert-error">' + (data.error || 'Unable to update password') + '</div>';
96
+ }
97
+ } catch (error) {
98
+ message.innerHTML = '<div class="alert alert-error">An error occurred</div>';
99
+ }
100
+ });
101
+ }
102
+
103
+ function initUploadPage() {
104
+ var tabs = document.querySelectorAll('.upload-tab');
105
+ var panels = document.querySelectorAll('.tab-panel');
106
+
107
+ if (!tabs.length || !panels.length) {
108
+ return;
109
+ }
110
+
111
+ tabs.forEach(function (tab) {
112
+ tab.addEventListener('click', function () {
113
+ tabs.forEach(function (item) {
114
+ item.classList.remove('active');
115
+ });
116
+ panels.forEach(function (panel) {
117
+ panel.classList.remove('active');
118
+ });
119
+
120
+ tab.classList.add('active');
121
+ var target = document.getElementById('tab-' + tab.dataset.tab);
122
+ if (target) {
123
+ target.classList.add('active');
124
+ }
125
+ });
126
+ });
127
+
128
+ function wireDropzone(options) {
129
+ var zone = document.getElementById(options.zoneId);
130
+ var input = document.getElementById(options.inputId);
131
+ var info = document.getElementById(options.infoId);
132
+ var label = document.getElementById(options.labelId);
133
+ var clearButton = document.querySelector(options.clearSel);
134
+ var submit = document.getElementById(options.submitId);
135
+ var form = document.getElementById(options.formId);
136
+ var overlay = document.getElementById(options.overlayId);
137
+
138
+ if (!zone || !input || !info || !label || !submit) {
139
+ return;
140
+ }
141
+
142
+ function showFiles(files) {
143
+ var validFiles = [];
144
+ for (var i = 0; i < files.length; i++) {
145
+ var name = files[i].name.toLowerCase();
146
+ if (name.endsWith('.dcm') || name.endsWith('.zip')) {
147
+ validFiles.push(files[i]);
148
+ }
149
+ }
150
+
151
+ if (!validFiles.length) {
152
+ return;
153
+ }
154
+
155
+ if (options.multi) {
156
+ var totalSizeMB = 0;
157
+ for (var j = 0; j < validFiles.length; j++) {
158
+ totalSizeMB += validFiles[j].size / (1024 * 1024);
159
+ }
160
+ label.textContent = validFiles.length + ' file' + (validFiles.length > 1 ? 's' : '') + ' (' + totalSizeMB.toFixed(1) + ' MB)';
161
+ } else {
162
+ label.textContent = validFiles[0].name;
163
+ }
164
+
165
+ info.style.display = 'flex';
166
+ zone.style.display = 'none';
167
+ submit.disabled = false;
168
+ }
169
+
170
+ function reset() {
171
+ input.value = '';
172
+ info.style.display = 'none';
173
+ zone.style.display = 'flex';
174
+ submit.disabled = true;
175
+ }
176
+
177
+ zone.addEventListener('click', function () {
178
+ input.click();
179
+ });
180
+
181
+ zone.addEventListener('dragover', function (event) {
182
+ event.preventDefault();
183
+ zone.classList.add('dragover');
184
+ });
185
+
186
+ zone.addEventListener('dragleave', function () {
187
+ zone.classList.remove('dragover');
188
+ });
189
+
190
+ zone.addEventListener('drop', function (event) {
191
+ event.preventDefault();
192
+ zone.classList.remove('dragover');
193
+ if (event.dataTransfer.files.length) {
194
+ input.files = event.dataTransfer.files;
195
+ showFiles(event.dataTransfer.files);
196
+ }
197
+ });
198
+
199
+ input.addEventListener('change', function () {
200
+ if (input.files.length) {
201
+ showFiles(input.files);
202
+ }
203
+ });
204
+
205
+ if (clearButton) {
206
+ clearButton.addEventListener('click', reset);
207
+ }
208
+
209
+ if (form && overlay) {
210
+ form.addEventListener('submit', function () {
211
+ overlay.style.display = 'flex';
212
+ submit.disabled = true;
213
+ });
214
+ }
215
+ }
216
+
217
+ wireDropzone({
218
+ zoneId: 'dropzoneSingle',
219
+ inputId: 'singleInput',
220
+ infoId: 'singleInfo',
221
+ labelId: 'singleFileName',
222
+ clearSel: '.js-clear-single',
223
+ submitId: 'singleSubmit',
224
+ formId: 'singleForm',
225
+ overlayId: 'singleOverlay',
226
+ multi: false
227
+ });
228
+
229
+ wireDropzone({
230
+ zoneId: 'dropzoneMulti',
231
+ inputId: 'multiInput',
232
+ infoId: 'multiInfo',
233
+ labelId: 'multiFileName',
234
+ clearSel: '.js-clear-multi',
235
+ submitId: 'multiSubmit',
236
+ formId: 'multiForm',
237
+ overlayId: 'multiOverlay',
238
+ multi: true
239
+ });
240
+
241
+ var dirInput = document.getElementById('dirPath');
242
+ var dirSubmit = document.getElementById('dirSubmit');
243
+
244
+ if (dirInput && dirSubmit) {
245
+ function checkDir() {
246
+ dirSubmit.disabled = !dirInput.value.trim();
247
+ }
248
+
249
+ dirInput.addEventListener('input', checkDir);
250
+ checkDir();
251
+ }
252
+ }
253
+
254
+ function initBatchProgress() {
255
+ var page = document.querySelector('.batch-page');
256
+
257
+ if (!page) {
258
+ return;
259
+ }
260
+
261
+ var statusUrl = page.dataset.statusUrl;
262
+ var reportsUrl = page.dataset.reportsUrl;
263
+ var pollMs = 1000;
264
+
265
+ var title = document.getElementById('batchTitle');
266
+ var subtitle = document.getElementById('batchSubtitle');
267
+ var fill = document.getElementById('progressFill');
268
+ var pctLabel = document.getElementById('progressPct');
269
+ var currentFile = document.getElementById('currentFile');
270
+ var statTotal = document.getElementById('statTotal');
271
+ var statProc = document.getElementById('statProcessed');
272
+ var statOK = document.getElementById('statSucceeded');
273
+ var statFail = document.getElementById('statFailed');
274
+ var feedPanel = document.getElementById('feedPanel');
275
+ var feedList = document.getElementById('batchFeed');
276
+ var donePanel = document.getElementById('donePanel');
277
+ var doneSummary = document.getElementById('doneSummary');
278
+ var failPanel = document.getElementById('failPanel');
279
+ var failList = document.getElementById('failList');
280
+ var prevIds = [];
281
+
282
+ if (!statusUrl || !title || !subtitle || !fill || !pctLabel || !currentFile || !statTotal || !statProc || !statOK || !statFail || !feedPanel || !feedList || !donePanel || !doneSummary || !failPanel || !failList) {
283
+ return;
284
+ }
285
+
286
+ function poll() {
287
+ fetch(statusUrl)
288
+ .then(function (response) {
289
+ return response.json();
290
+ })
291
+ .then(function (data) {
292
+ var pct = data.total > 0 ? Math.round(data.processed / data.total * 100) : 0;
293
+
294
+ statTotal.textContent = data.total;
295
+ statProc.textContent = data.processed;
296
+ statOK.textContent = data.succeeded;
297
+ statFail.textContent = data.failed_count;
298
+
299
+ fill.style.width = pct + '%';
300
+ pctLabel.textContent = pct + '%';
301
+
302
+ if (data.current_file) {
303
+ currentFile.textContent = 'Processing: ' + data.current_file;
304
+ } else {
305
+ currentFile.textContent = '';
306
+ }
307
+
308
+ if (data.image_ids && data.image_ids.length) {
309
+ feedPanel.style.display = 'block';
310
+ data.image_ids.forEach(function (imageId) {
311
+ if (prevIds.indexOf(imageId) === -1) {
312
+ prevIds.push(imageId);
313
+ var li = document.createElement('li');
314
+ var link = document.createElement('a');
315
+ link.href = '/case/' + imageId;
316
+ link.textContent = imageId;
317
+ li.appendChild(link);
318
+ feedList.insertBefore(li, feedList.firstChild);
319
+ while (feedList.children.length > 20) {
320
+ feedList.removeChild(feedList.lastChild);
321
+ }
322
+ }
323
+ });
324
+ }
325
+
326
+ if (data.status === 'completed' || data.status === 'failed') {
327
+ title.textContent = 'Batch Complete';
328
+ subtitle.textContent = '';
329
+ donePanel.style.display = 'block';
330
+ doneSummary.textContent = data.succeeded + ' of ' + data.total + ' files processed successfully' + (data.failed_count > 0 ? ', ' + data.failed_count + ' failed' : '') + '.';
331
+
332
+ if (data.failed_ids && data.failed_ids.length) {
333
+ failPanel.style.display = 'block';
334
+ data.failed_ids.forEach(function (failedId) {
335
+ var li = document.createElement('li');
336
+ li.textContent = failedId;
337
+ failList.appendChild(li);
338
+ });
339
+ }
340
+
341
+ if (reportsUrl) {
342
+ return;
343
+ }
344
+ return;
345
+ }
346
+
347
+ setTimeout(poll, pollMs);
348
+ })
349
+ .catch(function () {
350
+ setTimeout(poll, pollMs * 3);
351
+ });
352
+ }
353
+
354
+ poll();
355
+ }
356
+
357
+ function initPages() {
358
+ initUserMenu();
359
+ initPasswordModal();
360
+ initUploadPage();
361
+ initBatchProgress();
362
+ }
363
+
364
+ if (document.readyState === 'loading') {
365
+ document.addEventListener('DOMContentLoaded', initPages);
366
+ } else {
367
+ initPages();
368
+ }
369
+ })();
static/js/profile-page.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * profile-page.js β€” Profile page interactions
3
+ * Handles: pw-fields toggle, eye toggles, strength meter
4
+ * NOTE: AJAX password-change is still handled by profile.js (legacy)
5
+ * Depends on: auth-shared.js
6
+ */
7
+ document.addEventListener('DOMContentLoaded', function () {
8
+
9
+ /* ── Change-password field toggle ── */
10
+ const pwFields = document.getElementById('pwFields');
11
+ const pwToggleBtn = document.getElementById('pwToggleBtn');
12
+ const pwCancelBtn = document.getElementById('pwCancelBtn');
13
+
14
+ if (pwToggleBtn && pwFields) {
15
+ pwToggleBtn.addEventListener('click', function () {
16
+ pwFields.classList.add('active');
17
+ pwToggleBtn.style.display = 'none';
18
+ });
19
+ }
20
+
21
+ if (pwCancelBtn && pwFields) {
22
+ pwCancelBtn.addEventListener('click', function () {
23
+ pwFields.classList.remove('active');
24
+ if (pwToggleBtn) pwToggleBtn.style.display = '';
25
+ const form = document.getElementById('changePasswordForm');
26
+ if (form) form.reset();
27
+ const msg = document.getElementById('pwMessage');
28
+ if (msg) { msg.className = ''; msg.textContent = ''; }
29
+ // Reset strength bar
30
+ const bar = document.getElementById('profilePwBar');
31
+ const text = document.getElementById('profilePwText');
32
+ if (bar) { bar.className = 'pw-strength-fill'; }
33
+ if (text) { text.className = 'pw-strength-text'; text.textContent = ''; }
34
+ });
35
+ }
36
+
37
+ /* ── Eye toggles ── */
38
+ makePasswordToggle('toggleCur', 'currentPassword', 'eyeCur');
39
+ makePasswordToggle('toggleNew', 'newPassword', 'eyeNew');
40
+
41
+ /* ── Strength meter on new password ── */
42
+ passwordStrengthMeter('newPassword', 'profilePwBar', 'profilePwText');
43
+ });
static/js/profile.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function initProfilePage() {
3
+ var openButton = document.querySelector('.js-open-password-modal');
4
+ var closeButtons = document.querySelectorAll('.js-close-password-modal');
5
+ var modal = document.querySelector('.js-password-modal');
6
+ var form = document.getElementById('changePasswordForm');
7
+ var message = document.getElementById('passwordMessage');
8
+
9
+ if (!openButton || !closeButtons.length || !modal || !form || !message) {
10
+ return;
11
+ }
12
+
13
+ function openModal() {
14
+ modal.style.display = 'block';
15
+ form.reset();
16
+ message.innerHTML = '';
17
+ }
18
+
19
+ function closeModal() {
20
+ modal.style.display = 'none';
21
+ }
22
+
23
+ openButton.addEventListener('click', openModal);
24
+ closeButtons.forEach(function (button) {
25
+ button.addEventListener('click', closeModal);
26
+ });
27
+
28
+ document.addEventListener('click', function (event) {
29
+ if (event.target === modal) {
30
+ closeModal();
31
+ }
32
+ });
33
+
34
+ form.addEventListener('submit', async function (event) {
35
+ event.preventDefault();
36
+
37
+ var currentPassword = document.getElementById('currentPassword').value;
38
+ var newPassword = document.getElementById('newPassword').value;
39
+ var confirmPassword = document.getElementById('confirmPassword').value;
40
+ var endpoint = form.dataset.changePasswordUrl;
41
+
42
+ if (newPassword !== confirmPassword) {
43
+ message.innerHTML = '<div class="alert alert-error">Passwords do not match</div>';
44
+ return;
45
+ }
46
+
47
+ try {
48
+ var response = await fetch(endpoint, {
49
+ method: 'POST',
50
+ headers: {
51
+ 'Content-Type': 'application/json'
52
+ },
53
+ body: JSON.stringify({
54
+ current_password: currentPassword,
55
+ new_password: newPassword,
56
+ confirm_password: confirmPassword
57
+ })
58
+ });
59
+
60
+ var data = await response.json();
61
+
62
+ if (response.ok) {
63
+ message.innerHTML = '<div class="alert alert-success">' + data.message + '</div>';
64
+ setTimeout(closeModal, 2000);
65
+ } else {
66
+ message.innerHTML = '<div class="alert alert-error">' + (data.error || 'Unable to update password') + '</div>';
67
+ }
68
+ } catch (error) {
69
+ message.innerHTML = '<div class="alert alert-error">An error occurred</div>';
70
+ }
71
+ });
72
+ }
73
+
74
+ if (document.readyState === 'loading') {
75
+ document.addEventListener('DOMContentLoaded', initProfilePage);
76
+ } else {
77
+ initProfilePage();
78
+ }
79
+ })();
static/js/register.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * register.js β€” Registration page interactions
3
+ * Depends on: auth-shared.js
4
+ */
5
+ document.addEventListener('DOMContentLoaded', function () {
6
+ // Show/hide toggles for both password fields
7
+ makePasswordToggle('togglePw', 'password', 'eyeIcon');
8
+ makePasswordToggle('togglePw2', 'confirm_password', 'eyeIcon2');
9
+
10
+ // Live password strength meter
11
+ passwordStrengthMeter('password', 'pwBar', 'pwText');
12
+ });
static/js/upload.js ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function wireDropzone(options) {
3
+ var zone = document.getElementById(options.zoneId);
4
+ var input = document.getElementById(options.inputId);
5
+ var info = document.getElementById(options.infoId);
6
+ var label = document.getElementById(options.labelId);
7
+ var clearButton = document.querySelector(options.clearSel);
8
+ var submit = document.getElementById(options.submitId);
9
+ var form = document.getElementById(options.formId);
10
+ var overlay = document.getElementById(options.overlayId);
11
+
12
+ if (!zone || !input || !info || !label || !submit) {
13
+ return;
14
+ }
15
+
16
+ function showFiles(files) {
17
+ var validFiles = [];
18
+ for (var i = 0; i < files.length; i++) {
19
+ var name = files[i].name.toLowerCase();
20
+ if (name.endsWith('.dcm') || name.endsWith('.zip')) {
21
+ validFiles.push(files[i]);
22
+ }
23
+ }
24
+
25
+ if (!validFiles.length) {
26
+ return;
27
+ }
28
+
29
+ if (options.multi) {
30
+ var totalSizeMB = 0;
31
+ for (var j = 0; j < validFiles.length; j++) {
32
+ totalSizeMB += validFiles[j].size / (1024 * 1024);
33
+ }
34
+ label.textContent = validFiles.length + ' file' + (validFiles.length > 1 ? 's' : '') + ' (' + totalSizeMB.toFixed(1) + ' MB)';
35
+ } else {
36
+ label.textContent = validFiles[0].name;
37
+ }
38
+
39
+ info.style.display = 'flex';
40
+ zone.style.display = 'none';
41
+ submit.disabled = false;
42
+ }
43
+
44
+ function reset() {
45
+ input.value = '';
46
+ info.style.display = 'none';
47
+ zone.style.display = 'flex';
48
+ submit.disabled = true;
49
+ }
50
+
51
+ zone.addEventListener('click', function () {
52
+ input.click();
53
+ });
54
+
55
+ zone.addEventListener('dragover', function (event) {
56
+ event.preventDefault();
57
+ zone.classList.add('dragover');
58
+ });
59
+
60
+ zone.addEventListener('dragleave', function () {
61
+ zone.classList.remove('dragover');
62
+ });
63
+
64
+ zone.addEventListener('drop', function (event) {
65
+ event.preventDefault();
66
+ zone.classList.remove('dragover');
67
+ if (event.dataTransfer.files.length) {
68
+ input.files = event.dataTransfer.files;
69
+ showFiles(event.dataTransfer.files);
70
+ }
71
+ });
72
+
73
+ input.addEventListener('change', function () {
74
+ if (input.files.length) {
75
+ showFiles(input.files);
76
+ }
77
+ });
78
+
79
+ if (clearButton) {
80
+ clearButton.addEventListener('click', reset);
81
+ }
82
+
83
+ if (form && overlay) {
84
+ form.addEventListener('submit', function () {
85
+ overlay.style.display = 'flex';
86
+ submit.disabled = true;
87
+ });
88
+ }
89
+ }
90
+
91
+ function initUploadPage() {
92
+ var tabs = document.querySelectorAll('.upload-tab');
93
+ var panels = document.querySelectorAll('.tab-panel');
94
+
95
+ if (!tabs.length || !panels.length) {
96
+ return;
97
+ }
98
+
99
+ tabs.forEach(function (tab) {
100
+ tab.addEventListener('click', function () {
101
+ tabs.forEach(function (item) {
102
+ item.classList.remove('active');
103
+ });
104
+ panels.forEach(function (panel) {
105
+ panel.classList.remove('active');
106
+ });
107
+
108
+ tab.classList.add('active');
109
+ var target = document.getElementById('tab-' + tab.dataset.tab);
110
+ if (target) {
111
+ target.classList.add('active');
112
+ }
113
+ });
114
+ });
115
+
116
+ wireDropzone({
117
+ zoneId: 'dropzoneSingle',
118
+ inputId: 'singleInput',
119
+ infoId: 'singleInfo',
120
+ labelId: 'singleFileName',
121
+ clearSel: '.js-clear-single',
122
+ submitId: 'singleSubmit',
123
+ formId: 'singleForm',
124
+ overlayId: 'singleOverlay',
125
+ multi: false
126
+ });
127
+
128
+ wireDropzone({
129
+ zoneId: 'dropzoneMulti',
130
+ inputId: 'multiInput',
131
+ infoId: 'multiInfo',
132
+ labelId: 'multiFileName',
133
+ clearSel: '.js-clear-multi',
134
+ submitId: 'multiSubmit',
135
+ formId: 'multiForm',
136
+ overlayId: 'multiOverlay',
137
+ multi: true
138
+ });
139
+
140
+ var dirInput = document.getElementById('dirPath');
141
+ var dirSubmit = document.getElementById('dirSubmit');
142
+
143
+ if (dirInput && dirSubmit) {
144
+ function checkDir() {
145
+ dirSubmit.disabled = !dirInput.value.trim();
146
+ }
147
+
148
+ dirInput.addEventListener('input', checkDir);
149
+ checkDir();
150
+ }
151
+ }
152
+
153
+ if (document.readyState === 'loading') {
154
+ document.addEventListener('DOMContentLoaded', initUploadPage);
155
+ } else {
156
+ initUploadPage();
157
+ }
158
+ })();
static/styles.css CHANGED
@@ -1,3 +1,4 @@
 
1
  /* ═══════════════════════════════════════════════════════════════
2
  ICH Screening Dashboard β€” Stylesheet
3
  ═══════════════════════════════════════════════════════════════ */
@@ -980,6 +981,235 @@ a:hover {
980
  }
981
  }
982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  .steps-grid {
984
  display: grid;
985
  grid-template-columns: repeat(4, 1fr);
 
1
+ /* Shared user menu and profile modal styles */
2
  /* ═══════════════════════════════════════════════════════════════
3
  ICH Screening Dashboard β€” Stylesheet
4
  ═══════════════════════════════════════════════════════════════ */
 
981
  }
982
  }
983
 
984
+ .user-menu {
985
+ position: relative;
986
+ display: flex;
987
+ align-items: center;
988
+ }
989
+
990
+ .user-button {
991
+ display: flex;
992
+ align-items: center;
993
+ gap: 8px;
994
+ padding: 8px 16px;
995
+ background: none;
996
+ border: 1px solid #e5e7eb;
997
+ border-radius: 6px;
998
+ color: #374151;
999
+ font-size: 14px;
1000
+ font-weight: 500;
1001
+ cursor: pointer;
1002
+ transition: all 0.2s;
1003
+ }
1004
+
1005
+ .user-button:hover {
1006
+ background-color: #f9fafb;
1007
+ border-color: #d1d5db;
1008
+ }
1009
+
1010
+ .user-menu-dropdown {
1011
+ display: none;
1012
+ position: absolute;
1013
+ top: 100%;
1014
+ right: 0;
1015
+ margin-top: 8px;
1016
+ background: white;
1017
+ border: 1px solid #e5e7eb;
1018
+ border-radius: 6px;
1019
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
1020
+ min-width: 160px;
1021
+ z-index: 1000;
1022
+ }
1023
+
1024
+ .user-menu-dropdown.active {
1025
+ display: block;
1026
+ }
1027
+
1028
+ .menu-item {
1029
+ display: block;
1030
+ width: 100%;
1031
+ padding: 12px 16px;
1032
+ text-align: left;
1033
+ color: #374151;
1034
+ text-decoration: none;
1035
+ font-size: 14px;
1036
+ transition: all 0.2s;
1037
+ border: none;
1038
+ background: none;
1039
+ cursor: pointer;
1040
+ }
1041
+
1042
+ .menu-item:hover {
1043
+ background-color: #f3f4f6;
1044
+ color: #111827;
1045
+ }
1046
+
1047
+ .menu-item:first-child {
1048
+ border-radius: 5px 5px 0 0;
1049
+ }
1050
+
1051
+ .logout-btn {
1052
+ color: #dc2626;
1053
+ }
1054
+
1055
+ .logout-btn:hover {
1056
+ background-color: #fef2f2;
1057
+ color: #991b1b;
1058
+ }
1059
+
1060
+ .logout-form {
1061
+ width: 100%;
1062
+ margin: 0;
1063
+ }
1064
+
1065
+ .user-menu-dropdown hr {
1066
+ margin: 4px 0;
1067
+ border: none;
1068
+ border-top: 1px solid #e5e7eb;
1069
+ }
1070
+
1071
+ .auth-buttons {
1072
+ display: flex;
1073
+ gap: 10px;
1074
+ align-items: center;
1075
+ }
1076
+
1077
+ .profile-container {
1078
+ display: flex;
1079
+ justify-content: center;
1080
+ padding: 40px 20px;
1081
+ max-width: 600px;
1082
+ margin: 0 auto;
1083
+ }
1084
+
1085
+ .profile-card {
1086
+ background: white;
1087
+ border-radius: 8px;
1088
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
1089
+ padding: 40px;
1090
+ width: 100%;
1091
+ }
1092
+
1093
+ .profile-card h1 {
1094
+ margin-bottom: 30px;
1095
+ color: #333;
1096
+ }
1097
+
1098
+ .profile-section {
1099
+ margin-bottom: 30px;
1100
+ padding-bottom: 20px;
1101
+ border-bottom: 1px solid #eee;
1102
+ }
1103
+
1104
+ .profile-section h3 {
1105
+ color: #333;
1106
+ margin-bottom: 15px;
1107
+ }
1108
+
1109
+ .profile-item {
1110
+ display: flex;
1111
+ justify-content: space-between;
1112
+ padding: 12px 0;
1113
+ border-bottom: 1px solid #f5f5f5;
1114
+ }
1115
+
1116
+ .profile-label {
1117
+ font-weight: 500;
1118
+ color: #666;
1119
+ }
1120
+
1121
+ .profile-value {
1122
+ color: #333;
1123
+ }
1124
+
1125
+ .profile-footer {
1126
+ margin-top: 30px;
1127
+ display: flex;
1128
+ gap: 10px;
1129
+ }
1130
+
1131
+ .modal {
1132
+ display: none;
1133
+ position: fixed;
1134
+ z-index: 1000;
1135
+ left: 0;
1136
+ top: 0;
1137
+ width: 100%;
1138
+ height: 100%;
1139
+ background-color: rgba(0, 0, 0, 0.4);
1140
+ }
1141
+
1142
+ .modal-content {
1143
+ background-color: white;
1144
+ margin: 10% auto;
1145
+ padding: 30px;
1146
+ border-radius: 8px;
1147
+ width: 90%;
1148
+ max-width: 400px;
1149
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
1150
+ }
1151
+
1152
+ .close {
1153
+ color: #aaa;
1154
+ float: right;
1155
+ font-size: 28px;
1156
+ font-weight: bold;
1157
+ cursor: pointer;
1158
+ background: none;
1159
+ border: none;
1160
+ padding: 0;
1161
+ }
1162
+
1163
+ .close:hover {
1164
+ color: #000;
1165
+ }
1166
+
1167
+ .form-group {
1168
+ display: flex;
1169
+ flex-direction: column;
1170
+ margin-bottom: 15px;
1171
+ }
1172
+
1173
+ .form-group label {
1174
+ margin-bottom: 8px;
1175
+ font-weight: 500;
1176
+ color: #333;
1177
+ }
1178
+
1179
+ .form-group small {
1180
+ font-size: 12px;
1181
+ color: #666;
1182
+ margin-top: 4px;
1183
+ }
1184
+
1185
+ @media (max-width: 768px) {
1186
+ .user-button {
1187
+ padding: 8px 12px;
1188
+ font-size: 13px;
1189
+ }
1190
+
1191
+ .user-button svg {
1192
+ width: 18px;
1193
+ height: 18px;
1194
+ }
1195
+
1196
+ .auth-buttons {
1197
+ gap: 8px;
1198
+ }
1199
+
1200
+ .profile-container {
1201
+ padding: 24px 16px;
1202
+ }
1203
+
1204
+ .profile-card {
1205
+ padding: 24px 18px;
1206
+ }
1207
+
1208
+ .profile-item {
1209
+ flex-direction: column;
1210
+ gap: 4px;
1211
+ }
1212
+ }
1213
  .steps-grid {
1214
  display: grid;
1215
  grid-template-columns: repeat(4, 1fr);
templates/404.html ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Page Not Found β€” ICH Screening</title>
7
+ <meta name="description" content="The page you requested could not be found."/>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
11
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/error_pages.css') }}"/>
12
+ </head>
13
+ <body>
14
+ <div class="error-page">
15
+ <!-- background orbs -->
16
+ <div class="error-orb"></div>
17
+ <div class="error-orb"></div>
18
+ <div class="error-orb"></div>
19
+
20
+ <span class="error-badge error-badge-404">
21
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
22
+ Error 404
23
+ </span>
24
+
25
+ <!-- Glowing code -->
26
+ <div class="error-code-wrap">
27
+ <div class="error-code">404</div>
28
+ <div class="error-scanline"></div>
29
+ </div>
30
+
31
+ <!-- Inline SVG illustration (floating brain scan) -->
32
+ <div class="error-illustration">
33
+ <svg width="160" height="110" viewBox="0 0 160 110" fill="none" xmlns="http://www.w3.org/2000/svg">
34
+ <!-- CT scan ring -->
35
+ <ellipse cx="80" cy="55" rx="72" ry="48" stroke="#243356" stroke-width="1.5"/>
36
+ <ellipse cx="80" cy="55" rx="56" ry="36" stroke="#1e3060" stroke-width="1"/>
37
+ <!-- gantry frame -->
38
+ <rect x="8" y="18" width="12" height="74" rx="4" fill="#162244" stroke="#243356" stroke-width="1"/>
39
+ <rect x="140" y="18" width="12" height="74" rx="4" fill="#162244" stroke="#243356" stroke-width="1"/>
40
+ <!-- scan table -->
41
+ <rect x="28" y="50" width="104" height="10" rx="4" fill="#111c33" stroke="#243356" stroke-width="1"/>
42
+ <!-- question mark inside -->
43
+ <text x="80" y="63" text-anchor="middle" font-family="Inter,sans-serif" font-size="28" font-weight="900"
44
+ fill="url(#qgrad)" opacity=".9">?</text>
45
+ <!-- sweep arc -->
46
+ <path d="M80 7 A48 48 0 0 1 128 55" stroke="#6ea8fe" stroke-width="1.5" stroke-dasharray="6 4" opacity=".4"/>
47
+ <defs>
48
+ <linearGradient id="qgrad" x1="0" y1="0" x2="1" y2="1">
49
+ <stop offset="0%" stop-color="#6ea8fe"/>
50
+ <stop offset="100%" stop-color="#a78bfa"/>
51
+ </linearGradient>
52
+ </defs>
53
+ </svg>
54
+ </div>
55
+
56
+ <h1 class="error-title">Page Not Found</h1>
57
+ <p class="error-desc">
58
+ We couldn't find the page you were looking for. It may have been moved, deleted,
59
+ or the URL might be incorrect.
60
+ </p>
61
+
62
+ <div class="error-actions">
63
+ <a href="{{ url_for('home') }}" class="btn-err-primary">
64
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></svg>
65
+ Back to Home
66
+ </a>
67
+ <button onclick="history.back()" class="btn-err-secondary">
68
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
69
+ Go Back
70
+ </button>
71
+ </div>
72
+
73
+ <p class="error-footer">
74
+ <a href="{{ url_for('home') }}">ICH Screening</a> β€” AI-Assisted CT Hemorrhage Detection
75
+ </p>
76
+ </div>
77
+ </body>
78
+ </html>
templates/500.html ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Server Error β€” ICH Screening</title>
7
+ <meta name="description" content="An internal server error occurred. Our team has been notified."/>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
11
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/error_pages.css') }}"/>
12
+ </head>
13
+ <body>
14
+ <div class="error-page error-page-500">
15
+ <div class="error-orb"></div>
16
+ <div class="error-orb"></div>
17
+ <div class="error-orb"></div>
18
+
19
+ <span class="error-badge error-badge-500">
20
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
21
+ Error 500
22
+ </span>
23
+
24
+ <div class="error-code-wrap">
25
+ <div class="error-code">500</div>
26
+ <div class="error-scanline"></div>
27
+ </div>
28
+
29
+ <!-- Inline SVG β€” server/crash illustration -->
30
+ <div class="error-illustration">
31
+ <svg width="160" height="110" viewBox="0 0 160 110" fill="none" xmlns="http://www.w3.org/2000/svg">
32
+ <!-- server box -->
33
+ <rect x="30" y="20" width="100" height="70" rx="8" fill="#111c33" stroke="#243356" stroke-width="1.5"/>
34
+ <!-- server slots -->
35
+ <rect x="42" y="32" width="76" height="10" rx="3" fill="#0c1427" stroke="#1e3060" stroke-width="1"/>
36
+ <rect x="42" y="48" width="76" height="10" rx="3" fill="#0c1427" stroke="#1e3060" stroke-width="1"/>
37
+ <rect x="42" y="64" width="76" height="10" rx="3" fill="#0c1427" stroke="#1e3060" stroke-width="1"/>
38
+ <!-- led lights -->
39
+ <circle cx="50" cy="37" r="2.5" fill="#fb7185" opacity=".9"/>
40
+ <circle cx="50" cy="53" r="2.5" fill="#fbbf24" opacity=".7"/>
41
+ <circle cx="50" cy="69" r="2.5" fill="#243356"/>
42
+ <!-- lightning bolt -->
43
+ <path d="M92 14 L78 46 H88 L76 82 L104 44 H93 L104 14 Z"
44
+ fill="url(#boltgrad)" opacity=".85"/>
45
+ <defs>
46
+ <linearGradient id="boltgrad" x1="0" y1="0" x2="0" y2="1">
47
+ <stop offset="0%" stop-color="#fb7185"/>
48
+ <stop offset="100%" stop-color="#f97316"/>
49
+ </linearGradient>
50
+ </defs>
51
+ </svg>
52
+ </div>
53
+
54
+ <h1 class="error-title">Something Went Wrong</h1>
55
+ <p class="error-desc">
56
+ An unexpected error occurred on the server. Our team has been notified.
57
+ Please try again in a moment, or return to the home page.
58
+ </p>
59
+
60
+ <div class="error-actions">
61
+ <a href="{{ url_for('home') }}" class="btn-err-primary">
62
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></svg>
63
+ Back to Home
64
+ </a>
65
+ <button onclick="location.reload()" class="btn-err-secondary">
66
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
67
+ Retry
68
+ </button>
69
+ </div>
70
+
71
+ <p class="error-footer">
72
+ <a href="{{ url_for('home') }}">ICH Screening</a> β€” AI-Assisted CT Hemorrhage Detection
73
+ </p>
74
+ </div>
75
+ </body>
76
+ </html>
templates/auth/forgot_password.html ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Forgot Password β€” ICH Screening</title>
7
+ <meta name="description" content="Reset your ICH Screening account password."/>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
11
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}"/>
12
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"/>
13
+ </head>
14
+ <body>
15
+ <div class="auth-page">
16
+
17
+ <!-- ── Left brand panel ── -->
18
+ <aside class="auth-brand">
19
+ <div class="auth-brand-logo">
20
+ <div class="auth-brand-icon">
21
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
22
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
23
+ </svg>
24
+ </div>
25
+ <span class="auth-brand-name">ICH Screening</span>
26
+ </div>
27
+
28
+ <div class="auth-headline">
29
+ <h2>Secure <span class="grad">Account</span> Recovery</h2>
30
+ <p>We'll help you regain access to your account quickly and securely.</p>
31
+ </div>
32
+
33
+ <ul class="auth-features">
34
+ <li>
35
+ <span class="feat-icon">
36
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
37
+ </span>
38
+ Reset link sent to your email
39
+ </li>
40
+ <li>
41
+ <span class="feat-icon">
42
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
43
+ </span>
44
+ Secure token-based reset
45
+ </li>
46
+ <li>
47
+ <span class="feat-icon">
48
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
49
+ </span>
50
+ Link expires in 30 minutes
51
+ </li>
52
+ </ul>
53
+
54
+ <div class="auth-illustration">
55
+ <svg width="200" height="150" viewBox="0 0 200 150" fill="none" xmlns="http://www.w3.org/2000/svg">
56
+ <!-- envelope body -->
57
+ <rect x="20" y="55" width="160" height="88" rx="10" fill="#111c33" stroke="#243356" stroke-width="1.5"/>
58
+ <!-- envelope flap fold lines -->
59
+ <polyline points="20,55 100,108 180,55"
60
+ stroke="#1e3060" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
61
+ <!-- envelope top edge highlight -->
62
+ <line x1="20" y1="55" x2="180" y2="55" stroke="#243356" stroke-width="1"/>
63
+ <!-- lock body β€” centered in envelope: cx=100, cy=90 -->
64
+ <rect x="85" y="88" width="30" height="24" rx="4"
65
+ fill="#6ea8fe" opacity=".92"/>
66
+ <!-- lock shackle -->
67
+ <path d="M90 88 v-8 a10 10 0 0 1 20 0 v8"
68
+ stroke="#6ea8fe" stroke-width="3" stroke-linecap="round" fill="none"/>
69
+ <!-- keyhole dot -->
70
+ <circle cx="100" cy="100" r="3" fill="#0c1427"/>
71
+ <!-- subtle glow around lock -->
72
+ <circle cx="100" cy="97" r="22" stroke="#6ea8fe" stroke-width="1" opacity=".12"/>
73
+ </svg>
74
+ </div>
75
+ </aside>
76
+
77
+ <!-- ── Right form panel ── -->
78
+ <main class="auth-form-panel">
79
+ <div class="auth-card" id="formCard">
80
+ <div class="auth-card-header">
81
+ <h2>Forgot password?</h2>
82
+ <p>Enter your email and we'll send you a reset link</p>
83
+ </div>
84
+
85
+ {% with messages = get_flashed_messages(with_categories=true) %}
86
+ {% if messages %}
87
+ <div class="auth-alerts">
88
+ {% for category, message in messages %}
89
+ <div class="alert alert-{{ category }}">
90
+ {% if category == 'error' %}
91
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
92
+ {% else %}
93
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
94
+ {% endif %}
95
+ {{ message }}
96
+ </div>
97
+ {% endfor %}
98
+ </div>
99
+ {% endif %}
100
+ {% endwith %}
101
+
102
+ <!-- Success state (shown via JS or flash) -->
103
+ <div id="successState" style="display:none; text-align:center; padding: 16px 0;">
104
+ <div style="width:64px;height:64px;border-radius:50%;background:rgba(52,211,153,.12);border:1px solid rgba(52,211,153,.3);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
105
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
106
+ </div>
107
+ <h3 style="color:#e8ecf6;font-size:1.2rem;font-weight:800;margin-bottom:10px;">Check your inbox</h3>
108
+ <p style="color:#8ba0c4;font-size:.9rem;line-height:1.65;margin-bottom:24px;">
109
+ If that email address is registered, you'll receive a password reset link shortly. Check your spam folder if you don't see it.
110
+ </p>
111
+ <a href="{{ url_for('auth.login') }}" class="btn-auth-submit" style="display:block;text-decoration:none;text-align:center;">
112
+ Back to Sign In
113
+ </a>
114
+ </div>
115
+
116
+ <form method="POST" class="auth-form" id="fpForm">
117
+ <div class="form-group">
118
+ <label for="email">Email address</label>
119
+ <div class="input-wrap">
120
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
121
+ <input type="email" id="email" name="email" required autofocus
122
+ placeholder="your@email.com" autocomplete="email"/>
123
+ </div>
124
+ </div>
125
+
126
+ <button type="submit" class="btn-auth-submit">Send Reset Link</button>
127
+ </form>
128
+
129
+ <div class="auth-footer">
130
+ Remember your password? <a href="{{ url_for('auth.login') }}">Sign in</a>
131
+ </div>
132
+ </div>
133
+ </main>
134
+ </div>
135
+
136
+ <script src="{{ url_for('static', filename='js/forgot-password.js') }}" defer></script>
137
+ </body>
138
+ </html>
templates/auth/login.html ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Login β€” ICH Screening</title>
7
+ <meta name="description" content="Sign in to the ICH Screening AI dashboard."/>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
11
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}"/>
12
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"/>
13
+ </head>
14
+ <body>
15
+ <div class="auth-page">
16
+
17
+ <!-- ── Left brand panel ── -->
18
+ <aside class="auth-brand">
19
+ <div class="auth-brand-logo">
20
+ <div class="auth-brand-icon">
21
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
22
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
23
+ </svg>
24
+ </div>
25
+ <span class="auth-brand-name">ICH Screening</span>
26
+ </div>
27
+
28
+ <div class="auth-headline">
29
+ <h2>AI-Powered <span class="grad">Hemorrhage</span> Detection</h2>
30
+ <p>Clinical-grade CT scan analysis with Grad-CAM explainability and automated triage reporting.</p>
31
+ </div>
32
+
33
+ <ul class="auth-features">
34
+ <li>
35
+ <span class="feat-icon">
36
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
37
+ </span>
38
+ Deep learning ICH classification
39
+ </li>
40
+ <li>
41
+ <span class="feat-icon">
42
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
43
+ </span>
44
+ Grad-CAM heatmap visualisation
45
+ </li>
46
+ <li>
47
+ <span class="feat-icon">
48
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
49
+ </span>
50
+ Automated clinical PDF reports
51
+ </li>
52
+ <li>
53
+ <span class="feat-icon">
54
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
55
+ </span>
56
+ Secure, per-user data isolation
57
+ </li>
58
+ </ul>
59
+
60
+ <!-- CT Scanner Radar Illustration -->
61
+ <div class="auth-illustration">
62
+ <svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
63
+ <defs>
64
+ <radialGradient id="scanGlow" cx="50%" cy="50%" r="50%">
65
+ <stop offset="0%" stop-color="#6ea8fe" stop-opacity=".18"/>
66
+ <stop offset="100%" stop-color="#6ea8fe" stop-opacity="0"/>
67
+ </radialGradient>
68
+ <clipPath id="scanClip">
69
+ <circle cx="100" cy="100" r="96"/>
70
+ </clipPath>
71
+ </defs>
72
+
73
+ <!-- Scanner frame -->
74
+ <circle cx="100" cy="100" r="96" fill="#07101f" stroke="#243356" stroke-width="2"/>
75
+
76
+ <!-- Brain tissue layers (filled β€” depth via lightness) -->
77
+ <circle cx="100" cy="100" r="80" fill="#0b1728"/>
78
+ <circle cx="100" cy="100" r="64" fill="#0e1d32"/>
79
+ <circle cx="100" cy="100" r="46" fill="#111e34"/>
80
+ <circle cx="100" cy="100" r="26" fill="#09131e"/>
81
+
82
+ <!-- Layer stroke rings -->
83
+ <circle cx="100" cy="100" r="80" stroke="#1a2d4e" stroke-width="1"/>
84
+ <circle cx="100" cy="100" r="64" stroke="#162244" stroke-width=".75"/>
85
+ <circle cx="100" cy="100" r="46" stroke="#162244" stroke-width=".5"/>
86
+
87
+ <!-- Crosshair guide lines (clipped to scanner) -->
88
+ <g clip-path="url(#scanClip)" opacity=".22">
89
+ <line x1="4" y1="100" x2="196" y2="100" stroke="#6ea8fe" stroke-width=".75"/>
90
+ <line x1="100" y1="4" x2="100" y2="196" stroke="#6ea8fe" stroke-width=".75"/>
91
+ <line x1="29" y1="29" x2="171" y2="171" stroke="#6ea8fe" stroke-width=".5"/>
92
+ <line x1="171" y1="29" x2="29" y2="171" stroke="#6ea8fe" stroke-width=".5"/>
93
+ </g>
94
+
95
+ <!-- Radar sweep wedge: top-right quarter (0Β° β†’ 90Β°) -->
96
+ <!-- Wedge fill -->
97
+ <path d="M100,100 L100,20 A80,80 0 0,1 180,100 Z" fill="#6ea8fe" opacity=".07"/>
98
+ <!-- Trailing edge (vertical) -->
99
+ <line x1="100" y1="100" x2="100" y2="20" stroke="#6ea8fe" stroke-width="1" opacity=".4"/>
100
+ <!-- Leading edge (horizontal) -->
101
+ <line x1="100" y1="100" x2="180" y2="100" stroke="#6ea8fe" stroke-width="1.5" opacity=".85"/>
102
+ <!-- Arc from top to right β€” A80,80 0 0,1 means clockwise, rx=ry=80 -->
103
+ <path d="M100,20 A80,80 0 0,1 180,100" stroke="#6ea8fe" stroke-width="2" stroke-linecap="round" opacity=".9"/>
104
+
105
+ <!-- Anchor dots at sweep endpoints -->
106
+ <circle cx="100" cy="20" r="2.5" fill="#6ea8fe" opacity=".6"/>
107
+ <circle cx="180" cy="100" r="3.5" fill="#6ea8fe" opacity=".9"/>
108
+
109
+ <!-- Cardinal tick marks -->
110
+ <line x1="100" y1="4" x2="100" y2="14" stroke="#6ea8fe" stroke-width="2" stroke-linecap="round" opacity=".7"/>
111
+ <line x1="100" y1="186" x2="100" y2="196" stroke="#6ea8fe" stroke-width="2" stroke-linecap="round" opacity=".7"/>
112
+ <line x1="4" y1="100" x2="14" y2="100" stroke="#6ea8fe" stroke-width="2" stroke-linecap="round" opacity=".7"/>
113
+ <line x1="186" y1="100" x2="196" y2="100" stroke="#6ea8fe" stroke-width="2" stroke-linecap="round" opacity=".7"/>
114
+
115
+ <!-- Outer scanner ring overlay -->
116
+ <circle cx="100" cy="100" r="96" fill="none" stroke="#6ea8fe" stroke-width="1" opacity=".35"/>
117
+
118
+ <!-- Center reticle -->
119
+ <circle cx="100" cy="100" r="17" stroke="#6ea8fe" stroke-width=".75" fill="none" opacity=".2"/>
120
+ <circle cx="100" cy="100" r="10" stroke="#6ea8fe" stroke-width="1" fill="none" opacity=".45"/>
121
+ <circle cx="100" cy="100" r="4" fill="#6ea8fe" opacity=".95"/>
122
+
123
+ <!-- Radial glow overlay -->
124
+ <circle cx="100" cy="100" r="96" fill="url(#scanGlow)"/>
125
+ </svg>
126
+ </div>
127
+ </aside>
128
+
129
+ <!-- ── Right form panel ── -->
130
+ <main class="auth-form-panel">
131
+ <div class="auth-card">
132
+ <div class="auth-card-header">
133
+ <h2>Welcome back</h2>
134
+ <p>Sign in to your ICH Screening account</p>
135
+ </div>
136
+
137
+ {% with messages = get_flashed_messages(with_categories=true) %}
138
+ {% if messages %}
139
+ <div class="auth-alerts">
140
+ {% for category, message in messages %}
141
+ <div class="alert alert-{{ category }}">
142
+ {% if category == 'error' %}
143
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" flex-shrink="0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
144
+ {% else %}
145
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
146
+ {% endif %}
147
+ {{ message }}
148
+ </div>
149
+ {% endfor %}
150
+ </div>
151
+ {% endif %}
152
+ {% endwith %}
153
+
154
+ <form method="POST" class="auth-form" id="loginForm">
155
+ <div class="form-group">
156
+ <label for="username">Username</label>
157
+ <div class="input-wrap">
158
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
159
+ <input type="text" id="username" name="username" required autofocus
160
+ placeholder="Enter your username" autocomplete="username"/>
161
+ </div>
162
+ </div>
163
+
164
+ <div class="form-group">
165
+ <label for="password">Password</label>
166
+ <div class="input-wrap">
167
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
168
+ <input type="password" id="password" name="password" required
169
+ class="has-toggle" placeholder="Enter your password" autocomplete="current-password"/>
170
+ <button type="button" class="btn-pw-toggle" id="togglePw" aria-label="Toggle password visibility">
171
+ <svg id="eyeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
172
+ </button>
173
+ </div>
174
+ </div>
175
+
176
+ <div class="auth-row">
177
+ <label class="form-check">
178
+ <input type="checkbox" name="remember" id="remember" class="form-check-input"/>
179
+ <span class="form-check-label">Remember me</span>
180
+ </label>
181
+ <a href="{{ url_for('auth.forgot_password') }}" class="auth-link-sm">Forgot password?</a>
182
+ </div>
183
+
184
+ <button type="submit" class="btn-auth-submit" id="loginBtn">Sign In</button>
185
+ </form>
186
+
187
+ <div class="auth-footer">
188
+ Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a>
189
+ </div>
190
+ </div>
191
+ </main>
192
+ </div>
193
+
194
+ <script src="{{ url_for('static', filename='js/auth-shared.js') }}" defer></script>
195
+ <script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
196
+ </body>
197
+ </html>
templates/auth/profile.html ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Profile β€” ICH Screening{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="profile-page">
7
+
8
+ <!-- ── Profile hero ── -->
9
+ <div class="profile-hero">
10
+ <div class="profile-avatar" aria-label="User avatar">
11
+ {{ user.username[0].upper() }}
12
+ </div>
13
+ <div class="profile-identity">
14
+ <h2>{{ user.full_name or user.username }}</h2>
15
+ <div class="profile-email">{{ user.email }}</div>
16
+ <span class="profile-badge">
17
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
18
+ Member since {{ user.created_at.strftime('%B %Y') }}
19
+ </span>
20
+ </div>
21
+ </div>
22
+
23
+ <!-- ── Account info ── -->
24
+ <div class="profile-section">
25
+ <h3>
26
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
27
+ Account Information
28
+ </h3>
29
+ <div class="profile-row">
30
+ <span class="pr-label">Username</span>
31
+ <span class="pr-value">{{ user.username }}</span>
32
+ </div>
33
+ <div class="profile-row">
34
+ <span class="pr-label">Email</span>
35
+ <span class="pr-value">{{ user.email }}</span>
36
+ </div>
37
+ {% if user.full_name %}
38
+ <div class="profile-row">
39
+ <span class="pr-label">Full Name</span>
40
+ <span class="pr-value">{{ user.full_name }}</span>
41
+ </div>
42
+ {% endif %}
43
+ <div class="profile-row">
44
+ <span class="pr-label">Account Status</span>
45
+ <span class="pr-value" style="color:#34d399;">
46
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="margin-right:4px;vertical-align:middle"><circle cx="12" cy="12" r="10"/></svg>
47
+ Active
48
+ </span>
49
+ </div>
50
+ <div class="profile-row">
51
+ <span class="pr-label">Member Since</span>
52
+ <span class="pr-value">{{ user.created_at.strftime('%B %d, %Y') }}</span>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- ── Security ── -->
57
+ <div class="profile-section">
58
+ <h3>
59
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
60
+ Security
61
+ </h3>
62
+
63
+ <div id="pwMessage"></div>
64
+
65
+ <div class="pw-change-section">
66
+ <button class="pw-toggle-btn" id="pwToggleBtn" type="button">
67
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
68
+ Change Password
69
+ </button>
70
+
71
+ <div class="pw-change-fields" id="pwFields">
72
+ <form id="changePasswordForm" class="auth-form" style="gap:12px;"
73
+ data-change-password-url="{{ url_for('auth.change_password') }}">
74
+ <div class="form-group">
75
+ <label for="currentPassword">Current Password</label>
76
+ <div class="input-wrap">
77
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
78
+ <input type="password" id="currentPassword" name="current_password" required
79
+ class="has-toggle" placeholder="Enter current password"/>
80
+ <button type="button" class="btn-pw-toggle" id="toggleCur" aria-label="Toggle">
81
+ <svg id="eyeCur" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
82
+ </button>
83
+ </div>
84
+ </div>
85
+ <div class="form-group">
86
+ <label for="newPassword">New Password</label>
87
+ <div class="input-wrap">
88
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
89
+ <input type="password" id="newPassword" name="new_password" required
90
+ class="has-toggle" placeholder="8+ chars, upper, lower, digit"/>
91
+ <button type="button" class="btn-pw-toggle" id="toggleNew" aria-label="Toggle">
92
+ <svg id="eyeNew" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
93
+ </button>
94
+ </div>
95
+ <div class="pw-strength-bar"><div class="pw-strength-fill" id="profilePwBar"></div></div>
96
+ <span class="pw-strength-text" id="profilePwText"></span>
97
+ </div>
98
+ <div class="form-group">
99
+ <label for="confirmPassword">Confirm New Password</label>
100
+ <div class="input-wrap">
101
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
102
+ <input type="password" id="confirmPassword" name="confirm_password" required
103
+ class="has-toggle" placeholder="Re-enter new password"/>
104
+ </div>
105
+ </div>
106
+ <div class="pw-action-row">
107
+ <button type="submit" class="btn-save-pw">Update Password</button>
108
+ <button type="button" class="btn-cancel-pw" id="pwCancelBtn">Cancel</button>
109
+ </div>
110
+ </form>
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- ── Danger zone ── -->
116
+ <div class="profile-section profile-danger">
117
+ <h3>
118
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
119
+ Account Actions
120
+ </h3>
121
+ <form method="POST" action="{{ url_for('auth.logout') }}">
122
+ <button type="submit" class="btn-logout-danger">
123
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
124
+ Sign Out
125
+ </button>
126
+ </form>
127
+ </div>
128
+
129
+ </div>
130
+ {% endblock %}
131
+
132
+ {% block scripts %}
133
+ <script src="{{ url_for('static', filename='js/auth-shared.js') }}" defer></script>
134
+ <script src="{{ url_for('static', filename='js/profile.js') }}" defer></script>
135
+ <script src="{{ url_for('static', filename='js/profile-page.js') }}" defer></script>
136
+ {% endblock %}
137
+
templates/auth/register.html ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Create Account β€” ICH Screening</title>
7
+ <meta name="description" content="Register for the ICH Screening AI platform."/>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
11
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}"/>
12
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"/>
13
+ </head>
14
+ <body>
15
+ <div class="auth-page">
16
+
17
+ <!-- ── Left brand panel ── -->
18
+ <aside class="auth-brand">
19
+ <div class="auth-brand-logo">
20
+ <div class="auth-brand-icon">
21
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
22
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
23
+ </svg>
24
+ </div>
25
+ <span class="auth-brand-name">ICH Screening</span>
26
+ </div>
27
+
28
+ <div class="auth-headline">
29
+ <h2>Start Your <span class="grad">AI Screening</span> Journey</h2>
30
+ <p>Join clinicians and researchers using AI-powered CT analysis for faster, more accurate hemorrhage detection.</p>
31
+ </div>
32
+
33
+ <ul class="auth-features">
34
+ <li>
35
+ <span class="feat-icon">
36
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
37
+ </span>
38
+ Free to get started
39
+ </li>
40
+ <li>
41
+ <span class="feat-icon">
42
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
43
+ </span>
44
+ Your scans stay private
45
+ </li>
46
+ <li>
47
+ <span class="feat-icon">
48
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
49
+ </span>
50
+ Full calibration metrics & reports
51
+ </li>
52
+ <li>
53
+ <span class="feat-icon">
54
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
55
+ </span>
56
+ Results in seconds
57
+ </li>
58
+ </ul>
59
+
60
+ <!-- Scan-Complete Illustration -->
61
+ <div class="auth-illustration">
62
+ <svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
63
+ <defs>
64
+ <radialGradient id="regGlow" cx="50%" cy="50%" r="50%">
65
+ <stop offset="0%" stop-color="#34d399" stop-opacity=".15"/>
66
+ <stop offset="100%" stop-color="#34d399" stop-opacity="0"/>
67
+ </radialGradient>
68
+ <clipPath id="regClip">
69
+ <circle cx="100" cy="100" r="96"/>
70
+ </clipPath>
71
+ </defs>
72
+
73
+ <!-- Scanner frame -->
74
+ <circle cx="100" cy="100" r="96" fill="#07101f" stroke="#243356" stroke-width="2"/>
75
+
76
+ <!-- Brain tissue layers -->
77
+ <circle cx="100" cy="100" r="80" fill="#0b1f1a"/>
78
+ <circle cx="100" cy="100" r="64" fill="#0d2420"/>
79
+ <circle cx="100" cy="100" r="46" fill="#0f2822"/>
80
+ <circle cx="100" cy="100" r="26" fill="#091a14"/>
81
+
82
+ <!-- Layer stroke rings -->
83
+ <circle cx="100" cy="100" r="80" stroke="#163830" stroke-width="1"/>
84
+ <circle cx="100" cy="100" r="64" stroke="#1a4035" stroke-width=".75"/>
85
+
86
+ <!-- Crosshair guide lines (clipped) -->
87
+ <g clip-path="url(#regClip)" opacity=".2">
88
+ <line x1="4" y1="100" x2="196" y2="100" stroke="#34d399" stroke-width=".75"/>
89
+ <line x1="100" y1="4" x2="100" y2="196" stroke="#34d399" stroke-width=".75"/>
90
+ <line x1="29" y1="29" x2="171" y2="171" stroke="#34d399" stroke-width=".4"/>
91
+ <line x1="171" y1="29" x2="29" y2="171" stroke="#34d399" stroke-width=".4"/>
92
+ </g>
93
+
94
+ <!-- Full scan ring (all 360Β° β€” scan complete) -->
95
+ <circle cx="100" cy="100" r="80" fill="none" stroke="#34d399" stroke-width="1.5" opacity=".35"/>
96
+
97
+ <!-- Cardinal tick marks (green = complete) -->
98
+ <line x1="100" y1="4" x2="100" y2="14" stroke="#34d399" stroke-width="2" stroke-linecap="round" opacity=".8"/>
99
+ <line x1="100" y1="186" x2="100" y2="196" stroke="#34d399" stroke-width="2" stroke-linecap="round" opacity=".8"/>
100
+ <line x1="4" y1="100" x2="14" y2="100" stroke="#34d399" stroke-width="2" stroke-linecap="round" opacity=".8"/>
101
+ <line x1="186" y1="100" x2="196" y2="100" stroke="#34d399" stroke-width="2" stroke-linecap="round" opacity=".8"/>
102
+
103
+ <!-- Outer scanner ring overlay -->
104
+ <circle cx="100" cy="100" r="96" fill="none" stroke="#34d399" stroke-width="1" opacity=".35"/>
105
+
106
+ <!-- Center reticle circle -->
107
+ <circle cx="100" cy="100" r="22" fill="rgba(52,211,153,.1)" stroke="#34d399" stroke-width="1.5"/>
108
+ <circle cx="100" cy="100" r="32" stroke="#34d399" stroke-width=".75" fill="none" opacity=".2"/>
109
+
110
+ <!-- Checkmark inside reticle -->
111
+ <polyline points="89,100 97,109 114,88"
112
+ stroke="#34d399" stroke-width="2.5"
113
+ stroke-linecap="round" stroke-linejoin="round"/>
114
+
115
+ <!-- Glow overlay -->
116
+ <circle cx="100" cy="100" r="96" fill="url(#regGlow)"/>
117
+ </svg>
118
+ </div>
119
+ </aside>
120
+
121
+ <!-- ── Right form panel ── -->
122
+ <main class="auth-form-panel">
123
+ <div class="auth-card">
124
+ <div class="auth-card-header">
125
+ <h2>Create account</h2>
126
+ <p>Fill in your details to get started</p>
127
+ </div>
128
+
129
+ {% with messages = get_flashed_messages(with_categories=true) %}
130
+ {% if messages %}
131
+ <div class="auth-alerts">
132
+ {% for category, message in messages %}
133
+ <div class="alert alert-{{ category }}">
134
+ {% if category == 'error' %}
135
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
136
+ {% else %}
137
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
138
+ {% endif %}
139
+ {{ message }}
140
+ </div>
141
+ {% endfor %}
142
+ </div>
143
+ {% endif %}
144
+ {% endwith %}
145
+
146
+ <form method="POST" class="auth-form" id="registerForm">
147
+ <!-- Username -->
148
+ <div class="form-group">
149
+ <label for="username">Username</label>
150
+ <div class="input-wrap">
151
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
152
+ <input type="text" id="username" name="username" required autofocus
153
+ placeholder="3–80 chars, letters/numbers/-/_" autocomplete="username"/>
154
+ </div>
155
+ <span class="form-hint">Letters, numbers, hyphens and underscores only</span>
156
+ </div>
157
+
158
+ <!-- Email -->
159
+ <div class="form-group">
160
+ <label for="email">Email</label>
161
+ <div class="input-wrap">
162
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
163
+ <input type="email" id="email" name="email" required
164
+ placeholder="your@email.com" autocomplete="email"/>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Full Name (optional) -->
169
+ <div class="form-group">
170
+ <label for="full_name">Full Name <span style="color:#3d5482;font-weight:500;text-transform:none;letter-spacing:0">(optional)</span></label>
171
+ <div class="input-wrap">
172
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
173
+ <input type="text" id="full_name" name="full_name"
174
+ placeholder="Dr. Jane Smith" autocomplete="name"/>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- Password -->
179
+ <div class="form-group">
180
+ <label for="password">Password</label>
181
+ <div class="input-wrap">
182
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
183
+ <input type="password" id="password" name="password" required
184
+ class="has-toggle" placeholder="8+ chars, upper, lower, digit" autocomplete="new-password"/>
185
+ <button type="button" class="btn-pw-toggle" id="togglePw" aria-label="Toggle password visibility">
186
+ <svg id="eyeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
187
+ </button>
188
+ </div>
189
+ <div class="pw-strength-bar"><div class="pw-strength-fill" id="pwBar"></div></div>
190
+ <span class="pw-strength-text" id="pwText"></span>
191
+ </div>
192
+
193
+ <!-- Confirm Password -->
194
+ <div class="form-group">
195
+ <label for="confirm_password">Confirm Password</label>
196
+ <div class="input-wrap">
197
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
198
+ <input type="password" id="confirm_password" name="confirm_password" required
199
+ class="has-toggle" placeholder="Re-enter your password" autocomplete="new-password"/>
200
+ <button type="button" class="btn-pw-toggle" id="togglePw2" aria-label="Toggle confirm password">
201
+ <svg id="eyeIcon2" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
202
+ </button>
203
+ </div>
204
+ </div>
205
+
206
+ <button type="submit" class="btn-auth-submit">Create Account</button>
207
+ </form>
208
+
209
+ <div class="auth-footer">
210
+ Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a>
211
+ </div>
212
+ </div>
213
+ </main>
214
+ </div>
215
+
216
+ <script src="{{ url_for('static', filename='js/auth-shared.js') }}" defer></script>
217
+ <script src="{{ url_for('static', filename='js/register.js') }}" defer></script>
218
+ </body>
219
+ </html>
templates/base.html CHANGED
@@ -10,10 +10,11 @@
10
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
11
  rel="stylesheet"
12
  />
13
- <link
14
- rel="stylesheet"
15
- href="{{ url_for('static', filename='styles.css') }}"
16
- />
 
17
  {% block head %}{% endblock %}
18
  </head>
19
  <body>
@@ -39,19 +40,45 @@
39
  </a>
40
 
41
  <nav class="nav-links">
42
- <a href="{{ url_for('home') }}"
43
- class="{% if request.endpoint == 'home' %}active{% endif %}">Home</a>
44
- <a href="{{ url_for('upload') }}"
45
- class="{% if request.endpoint == 'upload' %}active{% endif %}">New Scan</a>
46
- <a href="{{ url_for('reports') }}"
47
- class="{% if request.endpoint == 'reports' %}active{% endif %}">Past Reports</a>
48
- <a href="{{ url_for('logs_page') }}"
49
- class="{% if request.endpoint == 'logs_page' %}active{% endif %}">Logs</a>
50
- <a href="{{ url_for('evaluation') }}"
51
- class="{% if request.endpoint == 'evaluation' %}active{% endif %}">Evaluation</a>
52
- <a href="{{ url_for('about') }}"
53
- class="{% if request.endpoint == 'about' %}active{% endif %}">About</a>
 
 
54
  </nav>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  </div>
56
  </header>
57
 
@@ -72,5 +99,6 @@
72
  </footer>
73
 
74
  {% block scripts %}{% endblock %}
 
75
  </body>
76
  </html>
 
10
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
11
  rel="stylesheet"
12
  />
13
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}" />
14
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}" />
15
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/pages.css') }}" />
16
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}" />
17
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}" />
18
  {% block head %}{% endblock %}
19
  </head>
20
  <body>
 
40
  </a>
41
 
42
  <nav class="nav-links">
43
+ {% if current_user.is_authenticated %}
44
+ <a href="{{ url_for('home') }}"
45
+ class="{% if request.endpoint == 'home' %}active{% endif %}">Home</a>
46
+ <a href="{{ url_for('upload') }}"
47
+ class="{% if request.endpoint == 'upload' %}active{% endif %}">New Scan</a>
48
+ <a href="{{ url_for('reports') }}"
49
+ class="{% if request.endpoint == 'reports' %}active{% endif %}">Past Reports</a>
50
+ <a href="{{ url_for('logs_page') }}"
51
+ class="{% if request.endpoint == 'logs_page' %}active{% endif %}">Logs</a>
52
+ <a href="{{ url_for('evaluation') }}"
53
+ class="{% if request.endpoint == 'evaluation' %}active{% endif %}">Evaluation</a>
54
+ <a href="{{ url_for('about') }}"
55
+ class="{% if request.endpoint == 'about' %}active{% endif %}">About</a>
56
+ {% endif %}
57
  </nav>
58
+
59
+ {% if current_user.is_authenticated %}
60
+ <div class="user-menu">
61
+ <button class="user-button" type="button" data-user-menu-toggle="true" title="User menu">
62
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
63
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
64
+ <circle cx="12" cy="7" r="4"></circle>
65
+ </svg>
66
+ <span>{{ current_user.username }}</span>
67
+ </button>
68
+ <div id="userMenuDropdown" class="user-menu-dropdown">
69
+ <a href="{{ url_for('auth.profile') }}" class="menu-item">Profile</a>
70
+ <hr>
71
+ <form method="POST" action="{{ url_for('auth.logout') }}" class="logout-form">
72
+ <button type="submit" class="menu-item logout-btn">Logout</button>
73
+ </form>
74
+ </div>
75
+ </div>
76
+ {% else %}
77
+ <div class="auth-buttons">
78
+ <a href="{{ url_for('auth.login') }}" class="btn btn-sm btn-outline">Login</a>
79
+ <a href="{{ url_for('auth.register') }}" class="btn btn-sm btn-primary">Register</a>
80
+ </div>
81
+ {% endif %}
82
  </div>
83
  </header>
84
 
 
99
  </footer>
100
 
101
  {% block scripts %}{% endblock %}
102
+ <script src="{{ url_for('static', filename='js/layout.js') }}" defer></script>
103
  </body>
104
  </html>
templates/batch_progress.html CHANGED
@@ -3,6 +3,7 @@
3
  {% block title %}Batch Processing β€” ICH Screening{% endblock %}
4
 
5
  {% block content %}
 
6
  <section class="breadcrumb">
7
  <a href="{{ url_for('home') }}">Home</a>
8
  <span class="sep">/</span>
@@ -76,109 +77,9 @@
76
  <h3 class="text-red">Failed Files</h3>
77
  <ul class="batch-fail-list" id="failList"></ul>
78
  </section>
 
79
  {% endblock %}
80
 
81
  {% block scripts %}
82
- <script>
83
- (function () {
84
- var BATCH_ID = "{{ batch_id }}";
85
- var POLL_MS = 1000;
86
- var statusUrl = "/batch/status/" + BATCH_ID;
87
- var reportsUrl = "{{ url_for('reports') }}";
88
-
89
- var title = document.getElementById("batchTitle");
90
- var subtitle = document.getElementById("batchSubtitle");
91
- var fill = document.getElementById("progressFill");
92
- var pctLabel = document.getElementById("progressPct");
93
- var currentFile = document.getElementById("currentFile");
94
- var statTotal = document.getElementById("statTotal");
95
- var statProc = document.getElementById("statProcessed");
96
- var statOK = document.getElementById("statSucceeded");
97
- var statFail = document.getElementById("statFailed");
98
- var feedPanel = document.getElementById("feedPanel");
99
- var feedList = document.getElementById("batchFeed");
100
- var donePanel = document.getElementById("donePanel");
101
- var doneSummary = document.getElementById("doneSummary");
102
- var failPanel = document.getElementById("failPanel");
103
- var failList = document.getElementById("failList");
104
-
105
- var prevIds = []; // track already-shown image_ids
106
-
107
- function poll() {
108
- fetch(statusUrl)
109
- .then(function (r) { return r.json(); })
110
- .then(function (d) {
111
- var pct = d.total > 0 ? Math.round(d.processed / d.total * 100) : 0;
112
-
113
- /* Update numbers */
114
- statTotal.textContent = d.total;
115
- statProc.textContent = d.processed;
116
- statOK.textContent = d.succeeded;
117
- statFail.textContent = d.failed_count;
118
-
119
- /* Progress bar */
120
- fill.style.width = pct + "%";
121
- pctLabel.textContent = pct + "%";
122
-
123
- /* Current file label */
124
- if (d.current_file) {
125
- currentFile.textContent = "Processing: " + d.current_file;
126
- } else {
127
- currentFile.textContent = "";
128
- }
129
-
130
- /* Live feed of recently processed IDs */
131
- if (d.image_ids && d.image_ids.length) {
132
- feedPanel.style.display = "block";
133
- d.image_ids.forEach(function (iid) {
134
- if (prevIds.indexOf(iid) === -1) {
135
- prevIds.push(iid);
136
- var li = document.createElement("li");
137
- var a = document.createElement("a");
138
- a.href = "/case/" + iid;
139
- a.textContent = iid;
140
- li.appendChild(a);
141
- feedList.insertBefore(li, feedList.firstChild);
142
- /* Keep max 20 items visible */
143
- while (feedList.children.length > 20) {
144
- feedList.removeChild(feedList.lastChild);
145
- }
146
- }
147
- });
148
- }
149
-
150
- /* Done? */
151
- if (d.status === "completed" || d.status === "failed") {
152
- title.textContent = "Batch Complete";
153
- subtitle.textContent = "";
154
- donePanel.style.display = "block";
155
- doneSummary.textContent =
156
- d.succeeded + " of " + d.total + " files processed successfully" +
157
- (d.failed_count > 0 ? ", " + d.failed_count + " failed" : "") + ".";
158
-
159
- /* Show failed files */
160
- if (d.failed_ids && d.failed_ids.length) {
161
- failPanel.style.display = "block";
162
- d.failed_ids.forEach(function (fid) {
163
- var li = document.createElement("li");
164
- li.textContent = fid;
165
- failList.appendChild(li);
166
- });
167
- }
168
- return; /* stop polling */
169
- }
170
-
171
- /* Keep polling */
172
- setTimeout(poll, POLL_MS);
173
- })
174
- .catch(function () {
175
- /* Network error β€” retry after a longer delay */
176
- setTimeout(poll, POLL_MS * 3);
177
- });
178
- }
179
-
180
- /* Start polling immediately */
181
- poll();
182
- })();
183
- </script>
184
  {% endblock %}
 
3
  {% block title %}Batch Processing β€” ICH Screening{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="batch-page" data-batch-id="{{ batch_id }}" data-status-url="{{ url_for('batch_status', batch_id=batch_id) }}" data-reports-url="{{ url_for('reports') }}">
7
  <section class="breadcrumb">
8
  <a href="{{ url_for('home') }}">Home</a>
9
  <span class="sep">/</span>
 
77
  <h3 class="text-red">Failed Files</h3>
78
  <ul class="batch-fail-list" id="failList"></ul>
79
  </section>
80
+ </div>
81
  {% endblock %}
82
 
83
  {% block scripts %}
84
+ <script src="{{ url_for('static', filename='js/batch.js') }}" defer></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  {% endblock %}
templates/home.html CHANGED
@@ -1,120 +1,146 @@
1
  {% extends "base.html" %}
2
 
3
- {% block title %}ICH Screening β€” Home{% endblock %}
 
 
 
 
4
 
5
  {% block content %}
6
- <section class="home-hero">
7
- <h1>ICH Screening System</h1>
 
 
 
 
 
 
8
  <p>
9
- AI-Assisted CT-Based Intracranial Hemorrhage Detection with
10
- Explainability and Clinical Reporting
11
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  </section>
13
 
14
- <!-- Quick stats row -->
15
  {% if stats.total > 0 %}
16
- <section class="stats-row home-stats">
17
- <div class="stat-card">
18
  <div class="stat-label">Total Scans</div>
19
- <div class="stat-value">{{ stats.total }}</div>
20
  </div>
21
- <div class="stat-card accent-red">
22
  <div class="stat-label">Positive</div>
23
- <div class="stat-value">{{ stats.positive }}</div>
24
  </div>
25
- <div class="stat-card accent-green">
26
  <div class="stat-label">Negative</div>
27
- <div class="stat-value">{{ stats.negative }}</div>
28
  </div>
29
- <div class="stat-card accent-orange">
30
  <div class="stat-label">Urgent</div>
31
- <div class="stat-value">{{ stats.urgent }}</div>
32
  </div>
33
- <div class="stat-card accent-blue">
34
  <div class="stat-label">Positivity Rate</div>
35
  <div class="stat-value">{{ '%.1f'|format(stats.pos_rate) }}%</div>
36
  </div>
37
- <div class="stat-card">
38
  <div class="stat-label">Avg Cal. Prob</div>
39
  <div class="stat-value">{{ '%.3f'|format(stats.avg_cal_prob) }}</div>
40
  </div>
41
  </section>
42
  {% endif %}
43
 
44
- <!-- Main action cards -->
45
- <section class="home-cards">
46
- <a href="{{ url_for('upload') }}" class="home-card">
47
- <div class="home-card-icon">
48
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
49
- stroke="currentColor" stroke-width="1.5"
50
- stroke-linecap="round" stroke-linejoin="round">
51
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
52
- <polyline points="17 8 12 3 7 8" />
53
- <line x1="12" y1="3" x2="12" y2="15" />
 
 
 
 
 
54
  </svg>
55
  </div>
56
  <h2>Upload Scans</h2>
57
- <p>
58
- Upload single or batch DICOM scans (.dcm / .zip) for AI-powered
59
- hemorrhage screening with Grad-CAM visualization.
60
- </p>
61
- <span class="home-card-action">Upload files &rarr;</span>
62
  </a>
63
 
64
- <a href="{{ url_for('reports') }}" class="home-card">
65
- <div class="home-card-icon">
66
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
67
- stroke="currentColor" stroke-width="1.5"
68
- stroke-linecap="round" stroke-linejoin="round">
69
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
70
- <polyline points="14 2 14 8 20 8" />
71
- <line x1="16" y1="13" x2="8" y2="13" />
72
- <line x1="16" y1="17" x2="8" y2="17" />
73
  </svg>
74
  </div>
75
  <h2>Past Reports</h2>
76
- <p>
77
- Browse {{ stats.total }} screening reports with confidence bands,
78
- triage actions, and Grad-CAM heatmaps.
79
- </p>
80
- <span class="home-card-action">View reports &rarr;</span>
81
  </a>
82
  </section>
83
 
84
- <!-- Secondary cards -->
85
- <section class="home-cards home-cards-secondary">
86
- <a href="{{ url_for('logs_page') }}" class="home-card home-card-sm">
87
- <div class="home-card-icon">
88
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none"
89
- stroke="currentColor" stroke-width="1.5">
90
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
91
- <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
92
  </svg>
93
  </div>
94
  <h3>Execution Logs</h3>
95
  <p class="muted small">{{ log_count }} inference trace{{ 's' if log_count != 1 }} recorded</p>
96
  </a>
97
 
98
- <a href="{{ url_for('evaluation') }}" class="home-card home-card-sm">
99
- <div class="home-card-icon">
100
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none"
101
- stroke="currentColor" stroke-width="1.5">
102
- <line x1="18" y1="20" x2="18" y2="10" />
103
- <line x1="12" y1="20" x2="12" y2="4" />
104
- <line x1="6" y1="20" x2="6" y2="14" />
105
  </svg>
106
  </div>
107
  <h3>Model Evaluation</h3>
108
  <p class="muted small">Calibration metrics and band analysis</p>
109
  </a>
110
 
111
- <a href="{{ url_for('about') }}" class="home-card home-card-sm">
112
- <div class="home-card-icon">
113
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none"
114
- stroke="currentColor" stroke-width="1.5">
115
- <circle cx="12" cy="12" r="10" />
116
- <line x1="12" y1="16" x2="12" y2="12" />
117
- <line x1="12" y1="8" x2="12.01" y2="8" />
118
  </svg>
119
  </div>
120
  <h3>About</h3>
@@ -122,9 +148,54 @@
122
  </a>
123
  </section>
124
 
125
- <section class="disclaimer-box" style="margin-top: 32px">
126
- <strong>Disclaimer:</strong>
127
- This is an AI-assisted screening tool and does NOT constitute a medical
128
- diagnosis. All findings must be reviewed by a qualified medical professional.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  {% endblock %}
 
1
  {% extends "base.html" %}
2
 
3
+ {% block title %}ICH Screening β€” Dashboard{% endblock %}
4
+
5
+ {% block head %}
6
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}"/>
7
+ {% endblock %}
8
 
9
  {% block content %}
10
+
11
+ <!-- ── Hero ── -->
12
+ <section class="landing-hero">
13
+ <div class="landing-badge">
14
+ <span class="badge-dot"></span>
15
+ AI-Powered Screening
16
+ </div>
17
+ <h1>Intracranial Hemorrhage<br><span class="hero-grad">Detection System</span></h1>
18
  <p>
19
+ Clinical-grade CT scan analysis powered by deep learning β€” with Grad-CAM visualisation,
20
+ automated triage, and exportable PDF reports.
21
  </p>
22
+ <div class="hero-cta-row">
23
+ <a href="{{ url_for('upload') }}" class="btn-hero-primary">
24
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
25
+ stroke-width="2.5" stroke-linecap="round">
26
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
27
+ <polyline points="17 8 12 3 7 8"/>
28
+ <line x1="12" y1="3" x2="12" y2="15"/>
29
+ </svg>
30
+ Upload a Scan
31
+ </a>
32
+ <a href="{{ url_for('reports') }}" class="btn-hero-secondary">
33
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
34
+ stroke-width="2" stroke-linecap="round">
35
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
36
+ <polyline points="14 2 14 8 20 8"/>
37
+ </svg>
38
+ View Reports
39
+ </a>
40
+ </div>
41
  </section>
42
 
43
+ <!-- ── Stats ── -->
44
  {% if stats.total > 0 %}
45
+ <section class="stats-section">
46
+ <div class="stat-card" style="animation-delay:.05s">
47
  <div class="stat-label">Total Scans</div>
48
+ <div class="stat-value" data-count="{{ stats.total }}">0</div>
49
  </div>
50
+ <div class="stat-card accent-red" style="animation-delay:.1s">
51
  <div class="stat-label">Positive</div>
52
+ <div class="stat-value" data-count="{{ stats.positive }}">0</div>
53
  </div>
54
+ <div class="stat-card accent-green" style="animation-delay:.15s">
55
  <div class="stat-label">Negative</div>
56
+ <div class="stat-value" data-count="{{ stats.negative }}">0</div>
57
  </div>
58
+ <div class="stat-card accent-orange" style="animation-delay:.2s">
59
  <div class="stat-label">Urgent</div>
60
+ <div class="stat-value" data-count="{{ stats.urgent }}">0</div>
61
  </div>
62
+ <div class="stat-card accent-blue" style="animation-delay:.25s">
63
  <div class="stat-label">Positivity Rate</div>
64
  <div class="stat-value">{{ '%.1f'|format(stats.pos_rate) }}%</div>
65
  </div>
66
+ <div class="stat-card" style="animation-delay:.3s">
67
  <div class="stat-label">Avg Cal. Prob</div>
68
  <div class="stat-value">{{ '%.3f'|format(stats.avg_cal_prob) }}</div>
69
  </div>
70
  </section>
71
  {% endif %}
72
 
73
+ <!-- ── Quick Actions heading ── -->
74
+ <div class="section-heading">
75
+ <h2>Quick Actions</h2>
76
+ <div class="section-line"></div>
77
+ </div>
78
+
79
+ <!-- ── Main action cards ── -->
80
+ <section class="action-cards">
81
+ <a href="{{ url_for('upload') }}" class="action-card">
82
+ <div class="action-card-icon">
83
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor"
84
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
85
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
86
+ <polyline points="17 8 12 3 7 8"/>
87
+ <line x1="12" y1="3" x2="12" y2="15"/>
88
  </svg>
89
  </div>
90
  <h2>Upload Scans</h2>
91
+ <p>Upload single or batch DICOM scans (.dcm / .zip) for AI-powered hemorrhage screening
92
+ with Grad-CAM heatmap visualisation.</p>
93
+ <span class="action-card-cta">Upload files β†’</span>
 
 
94
  </a>
95
 
96
+ <a href="{{ url_for('reports') }}" class="action-card">
97
+ <div class="action-card-icon">
98
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor"
99
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
100
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
101
+ <polyline points="14 2 14 8 20 8"/>
102
+ <line x1="16" y1="13" x2="8" y2="13"/>
103
+ <line x1="16" y1="17" x2="8" y2="17"/>
 
104
  </svg>
105
  </div>
106
  <h2>Past Reports</h2>
107
+ <p>Browse {{ stats.total }} screening report{{ 's' if stats.total != 1 }} with confidence bands,
108
+ triage actions, and Grad-CAM heatmaps.</p>
109
+ <span class="action-card-cta">View reports β†’</span>
 
 
110
  </a>
111
  </section>
112
 
113
+ <!-- ── Mini cards ── -->
114
+ <section class="mini-cards">
115
+ <a href="{{ url_for('logs_page') }}" class="mini-card">
116
+ <div class="mini-card-icon">
117
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
118
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
119
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
 
120
  </svg>
121
  </div>
122
  <h3>Execution Logs</h3>
123
  <p class="muted small">{{ log_count }} inference trace{{ 's' if log_count != 1 }} recorded</p>
124
  </a>
125
 
126
+ <a href="{{ url_for('evaluation') }}" class="mini-card">
127
+ <div class="mini-card-icon">
128
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
129
+ <line x1="18" y1="20" x2="18" y2="10"/>
130
+ <line x1="12" y1="20" x2="12" y2="4"/>
131
+ <line x1="6" y1="20" x2="6" y2="14"/>
 
132
  </svg>
133
  </div>
134
  <h3>Model Evaluation</h3>
135
  <p class="muted small">Calibration metrics and band analysis</p>
136
  </a>
137
 
138
+ <a href="{{ url_for('about') }}" class="mini-card">
139
+ <div class="mini-card-icon">
140
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
141
+ <circle cx="12" cy="12" r="10"/>
142
+ <line x1="12" y1="16" x2="12" y2="12"/>
143
+ <line x1="12" y1="8" x2="12.01" y2="8"/>
 
144
  </svg>
145
  </div>
146
  <h3>About</h3>
 
148
  </a>
149
  </section>
150
 
151
+ <!-- ── How it works ── -->
152
+ <section class="how-section">
153
+ <div class="section-heading">
154
+ <h2>How It Works</h2>
155
+ <div class="section-line"></div>
156
+ </div>
157
+ <div class="how-steps">
158
+ <div class="how-step">
159
+ <div class="how-num">1</div>
160
+ <h4>Upload DICOM</h4>
161
+ <p>Upload a .dcm file or a .zip batch. Single slices or full series are both supported.</p>
162
+ </div>
163
+ <div class="how-step">
164
+ <div class="how-num">2</div>
165
+ <h4>AI Inference</h4>
166
+ <p>A calibrated deep-learning model scores each slice for ICH probability.</p>
167
+ </div>
168
+ <div class="how-step">
169
+ <div class="how-num">3</div>
170
+ <h4>Grad-CAM Heatmap</h4>
171
+ <p>Gradient-weighted class activation maps highlight regions driving the prediction.</p>
172
+ </div>
173
+ <div class="how-step">
174
+ <div class="how-num">4</div>
175
+ <h4>Clinical Report</h4>
176
+ <p>An auto-generated PDF report with findings, confidence bands, and triage action.</p>
177
+ </div>
178
+ </div>
179
  </section>
180
+
181
+ <!-- ── Disclaimer ── -->
182
+ <div class="disclaimer-box" style="margin-top:36px;">
183
+ <svg class="disclaimer-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
184
+ stroke="currentColor" stroke-width="2">
185
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
186
+ <line x1="12" y1="9" x2="12" y2="13"/>
187
+ <line x1="12" y1="17" x2="12.01" y2="17"/>
188
+ </svg>
189
+ <div>
190
+ <strong>Medical Disclaimer:</strong>
191
+ This is an AI-assisted screening tool and does <strong>not</strong> constitute a medical diagnosis.
192
+ All findings must be reviewed and confirmed by a qualified medical professional before
193
+ any clinical action is taken.
194
+ </div>
195
+ </div>
196
+
197
+ {% endblock %}
198
+
199
+ {% block scripts %}
200
+ <script src="{{ url_for('static', filename='js/home.js') }}" defer></script>
201
  {% endblock %}
templates/upload.html CHANGED
@@ -197,135 +197,5 @@
197
  {% endblock %}
198
 
199
  {% block scripts %}
200
- <script>
201
- (function () {
202
- /* ── Tab switching ────────────────────────────────────────────────── */
203
- var tabs = document.querySelectorAll(".upload-tab");
204
- var panels = document.querySelectorAll(".tab-panel");
205
-
206
- tabs.forEach(function (tab) {
207
- tab.addEventListener("click", function () {
208
- tabs.forEach(function (t) { t.classList.remove("active"); });
209
- panels.forEach(function (p) { p.classList.remove("active"); });
210
- tab.classList.add("active");
211
- var target = document.getElementById("tab-" + tab.dataset.tab);
212
- if (target) target.classList.add("active");
213
- });
214
- });
215
-
216
- /* ── Helper: generic dropzone wiring ─────────────────────────────── */
217
- function wireDropzone(opts) {
218
- var zone = document.getElementById(opts.zoneId);
219
- var input = document.getElementById(opts.inputId);
220
- var info = document.getElementById(opts.infoId);
221
- var label = document.getElementById(opts.labelId);
222
- var clear = document.querySelector(opts.clearSel);
223
- var submit = document.getElementById(opts.submitId);
224
- var form = document.getElementById(opts.formId);
225
- var overlay = document.getElementById(opts.overlayId);
226
-
227
- if (!zone || !input) return;
228
-
229
- function showFiles(files) {
230
- var validFiles = [];
231
- for (var i = 0; i < files.length; i++) {
232
- var name = files[i].name.toLowerCase();
233
- if (name.endsWith(".dcm") || name.endsWith(".zip")) {
234
- validFiles.push(files[i]);
235
- }
236
- }
237
- if (!validFiles.length) return;
238
-
239
- if (opts.multi) {
240
- var totalSizeMB = 0;
241
- for (var j = 0; j < validFiles.length; j++) {
242
- totalSizeMB += validFiles[j].size / (1024 * 1024);
243
- }
244
- label.textContent = validFiles.length + " file" +
245
- (validFiles.length > 1 ? "s" : "") +
246
- " (" + totalSizeMB.toFixed(1) + " MB)";
247
- } else {
248
- label.textContent = validFiles[0].name;
249
- }
250
-
251
- info.style.display = "flex";
252
- zone.style.display = "none";
253
- submit.disabled = false;
254
- }
255
-
256
- function reset() {
257
- input.value = "";
258
- info.style.display = "none";
259
- zone.style.display = "flex";
260
- submit.disabled = true;
261
- }
262
-
263
- zone.addEventListener("click", function () { input.click(); });
264
-
265
- zone.addEventListener("dragover", function (e) {
266
- e.preventDefault();
267
- zone.classList.add("dragover");
268
- });
269
- zone.addEventListener("dragleave", function () {
270
- zone.classList.remove("dragover");
271
- });
272
- zone.addEventListener("drop", function (e) {
273
- e.preventDefault();
274
- zone.classList.remove("dragover");
275
- if (e.dataTransfer.files.length) {
276
- input.files = e.dataTransfer.files;
277
- showFiles(e.dataTransfer.files);
278
- }
279
- });
280
-
281
- input.addEventListener("change", function () {
282
- if (input.files.length) showFiles(input.files);
283
- });
284
-
285
- if (clear) clear.addEventListener("click", reset);
286
-
287
- if (form && overlay) {
288
- form.addEventListener("submit", function () {
289
- overlay.style.display = "flex";
290
- submit.disabled = true;
291
- });
292
- }
293
- }
294
-
295
- /* ── Wire single-file dropzone ───────────────────────────────────── */
296
- wireDropzone({
297
- zoneId: "dropzoneSingle",
298
- inputId: "singleInput",
299
- infoId: "singleInfo",
300
- labelId: "singleFileName",
301
- clearSel: ".js-clear-single",
302
- submitId: "singleSubmit",
303
- formId: "singleForm",
304
- overlayId: "singleOverlay",
305
- multi: false,
306
- });
307
-
308
- /* ── Wire multi-file dropzone ────────────────────────────────────── */
309
- wireDropzone({
310
- zoneId: "dropzoneMulti",
311
- inputId: "multiInput",
312
- infoId: "multiInfo",
313
- labelId: "multiFileName",
314
- clearSel: ".js-clear-multi",
315
- submitId: "multiSubmit",
316
- formId: "multiForm",
317
- overlayId: "multiOverlay",
318
- multi: true,
319
- });
320
-
321
- /* ── Directory scan: disable submit when input is empty ──────────── */
322
- var dirInput = document.getElementById("dirPath");
323
- var dirSubmit = document.getElementById("dirSubmit");
324
- if (dirInput && dirSubmit) {
325
- function checkDir() { dirSubmit.disabled = !dirInput.value.trim(); }
326
- dirInput.addEventListener("input", checkDir);
327
- checkDir();
328
- }
329
- })();
330
- </script>
331
  {% endblock %}
 
197
  {% endblock %}
198
 
199
  {% block scripts %}
200
+ <script src="{{ url_for('static', filename='js/upload.js') }}" defer></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  {% endblock %}