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 +40 -15
- .gitignore +2 -0
- app_new.py +972 -0
- auth_routes.py +205 -0
- auth_utils.py +111 -0
- data_isolation.py +213 -0
- models.py +114 -0
- render.yaml +16 -0
- requirements.txt +29 -12
- security.py +231 -0
- static/css/auth.css +356 -0
- static/css/base.css +229 -0
- static/css/components.css +139 -0
- static/css/error_pages.css +197 -0
- static/css/home.css +364 -0
- static/css/pages.css +1078 -0
- static/css/responsive.css +75 -0
- static/js/auth-shared.js +61 -0
- static/js/batch.js +99 -0
- static/js/forgot-password.js +15 -0
- static/js/home.js +23 -0
- static/js/layout.js +29 -0
- static/js/login.js +7 -0
- static/js/pages.js +369 -0
- static/js/profile-page.js +43 -0
- static/js/profile.js +79 -0
- static/js/register.js +12 -0
- static/js/upload.js +158 -0
- static/styles.css +230 -0
- templates/404.html +78 -0
- templates/500.html +76 -0
- templates/auth/forgot_password.html +138 -0
- templates/auth/login.html +197 -0
- templates/auth/profile.html +137 -0
- templates/auth/register.html +219 -0
- templates/base.html +44 -16
- templates/batch_progress.html +3 -102
- templates/home.html +143 -72
- templates/upload.html +1 -131
.env.example
CHANGED
|
@@ -1,24 +1,49 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
ICH_APP_PORT=7860
|
| 4 |
-
ICH_SECRET_KEY=change-me-in-production
|
| 5 |
|
| 6 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
ICH_MAX_UPLOAD_MB=2048
|
|
|
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
#
|
|
|
|
|
|
|
| 11 |
ICH_FOLD_SELECTION=ensemble
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
#
|
|
|
|
| 18 |
ICH_LOG_LEVEL=INFO
|
|
|
|
| 19 |
|
| 20 |
-
#
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
#
|
| 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 |
-
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
/>
|
|
|
|
| 17 |
{% block head %}{% endblock %}
|
| 18 |
</head>
|
| 19 |
<body>
|
|
@@ -39,19 +40,45 @@
|
|
| 39 |
</a>
|
| 40 |
|
| 41 |
<nav class="nav-links">
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 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 β
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
{% block content %}
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
<p>
|
| 9 |
-
|
| 10 |
-
|
| 11 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
</section>
|
| 13 |
|
| 14 |
-
<!--
|
| 15 |
{% if stats.total > 0 %}
|
| 16 |
-
<section class="stats-
|
| 17 |
-
<div class="stat-card">
|
| 18 |
<div class="stat-label">Total Scans</div>
|
| 19 |
-
<div class="stat-value"
|
| 20 |
</div>
|
| 21 |
-
<div class="stat-card accent-red">
|
| 22 |
<div class="stat-label">Positive</div>
|
| 23 |
-
<div class="stat-value"
|
| 24 |
</div>
|
| 25 |
-
<div class="stat-card accent-green">
|
| 26 |
<div class="stat-label">Negative</div>
|
| 27 |
-
<div class="stat-value"
|
| 28 |
</div>
|
| 29 |
-
<div class="stat-card accent-orange">
|
| 30 |
<div class="stat-label">Urgent</div>
|
| 31 |
-
<div class="stat-value"
|
| 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 |
-
<!--
|
| 45 |
-
<
|
| 46 |
-
<
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</svg>
|
| 55 |
</div>
|
| 56 |
<h2>Upload Scans</h2>
|
| 57 |
-
<p>
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
</p>
|
| 61 |
-
<span class="home-card-action">Upload files →</span>
|
| 62 |
</a>
|
| 63 |
|
| 64 |
-
<a href="{{ url_for('reports') }}" class="
|
| 65 |
-
<div class="
|
| 66 |
-
<svg width="
|
| 67 |
-
stroke="
|
| 68 |
-
|
| 69 |
-
<
|
| 70 |
-
<
|
| 71 |
-
<line x1="16" y1="
|
| 72 |
-
<line x1="16" y1="17" x2="8" y2="17" />
|
| 73 |
</svg>
|
| 74 |
</div>
|
| 75 |
<h2>Past Reports</h2>
|
| 76 |
-
<p>
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
</p>
|
| 80 |
-
<span class="home-card-action">View reports →</span>
|
| 81 |
</a>
|
| 82 |
</section>
|
| 83 |
|
| 84 |
-
<!--
|
| 85 |
-
<section class="
|
| 86 |
-
<a href="{{ url_for('logs_page') }}" class="
|
| 87 |
-
<div class="
|
| 88 |
-
<svg width="
|
| 89 |
-
|
| 90 |
-
<path d="
|
| 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="
|
| 99 |
-
<div class="
|
| 100 |
-
<svg width="
|
| 101 |
-
|
| 102 |
-
<line x1="
|
| 103 |
-
<line x1="
|
| 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="
|
| 112 |
-
<div class="
|
| 113 |
-
<svg width="
|
| 114 |
-
|
| 115 |
-
<
|
| 116 |
-
<line x1="12" y1="
|
| 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 |
-
<
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 %}
|