Spaces:
Running
Running
RELEASE: auth; pf section added; bug/crash/ major improvements & fixes; refactoring pending..
Browse files- backend/auth.py +85 -0
- backend/email_template.html +16 -0
- backend/engine.py +94 -100
- backend/geometry.py +12 -0
- backend/server.py +130 -38
- frontend/css/auth.css +323 -0
- frontend/css/initial.css +42 -26
- frontend/css/shared.css +281 -0
- frontend/css/vehicles.css +1525 -1410
- frontend/initial.html +49 -109
- frontend/js/auth.js +375 -0
- frontend/js/initial.js +33 -7
- frontend/js/shared.js +266 -0
- frontend/js/templates.js +126 -0
- frontend/js/vehicles.js +247 -36
- frontend/sw.js +2 -0
- frontend/vehicles.html +266 -358
- requirements.txt +1 -0
backend/auth.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Google OAuth token verification and lightweight user store.
|
| 3 |
+
|
| 4 |
+
Uses google-auth to verify JWT credentials issued by Google Identity Services.
|
| 5 |
+
User profiles are stored in a JSON file (/tmp) — ephemeral on HF Spaces,
|
| 6 |
+
which is acceptable for a demo. Client-side localStorage persists the session.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
|
| 14 |
+
from google.oauth2 import id_token
|
| 15 |
+
from google.auth.transport import requests as google_requests
|
| 16 |
+
|
| 17 |
+
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
| 18 |
+
USER_STORE_PATH = Path(os.getenv("USER_STORE_PATH", "/tmp/urbanflow_users.json"))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _load_users() -> dict:
|
| 22 |
+
if USER_STORE_PATH.exists():
|
| 23 |
+
return json.loads(USER_STORE_PATH.read_text())
|
| 24 |
+
return {}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _save_users(users: dict):
|
| 28 |
+
USER_STORE_PATH.write_text(json.dumps(users, indent=2))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def verify_google_token(credential: str) -> dict:
|
| 32 |
+
"""
|
| 33 |
+
Verify a Google ID token (JWT) and return the decoded payload.
|
| 34 |
+
Raises ValueError on invalid/expired tokens.
|
| 35 |
+
"""
|
| 36 |
+
idinfo = id_token.verify_oauth2_token(
|
| 37 |
+
credential,
|
| 38 |
+
google_requests.Request(),
|
| 39 |
+
GOOGLE_CLIENT_ID,
|
| 40 |
+
)
|
| 41 |
+
return {
|
| 42 |
+
"email": idinfo["email"],
|
| 43 |
+
"name": idinfo.get("name", ""),
|
| 44 |
+
"picture": idinfo.get("picture", ""),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_or_create_user(email: str, name: str, picture: str) -> dict:
|
| 49 |
+
"""
|
| 50 |
+
Look up a user by email. If they don't exist, create a stub record.
|
| 51 |
+
Returns the user record with a `new_user` flag.
|
| 52 |
+
"""
|
| 53 |
+
users = _load_users()
|
| 54 |
+
is_new = email not in users
|
| 55 |
+
|
| 56 |
+
if is_new:
|
| 57 |
+
users[email] = {
|
| 58 |
+
"username": "",
|
| 59 |
+
"name": name,
|
| 60 |
+
"picture": picture,
|
| 61 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 62 |
+
}
|
| 63 |
+
_save_users(users)
|
| 64 |
+
|
| 65 |
+
user = users[email]
|
| 66 |
+
return {
|
| 67 |
+
"email": email,
|
| 68 |
+
"username": user.get("username", ""),
|
| 69 |
+
"name": user.get("name", name),
|
| 70 |
+
"picture": user.get("picture", picture),
|
| 71 |
+
"new_user": is_new or not user.get("username"),
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def set_username(email: str, username: str) -> bool:
|
| 76 |
+
"""
|
| 77 |
+
Save a display username for a first-time user.
|
| 78 |
+
Returns True on success, False if the user record doesn't exist.
|
| 79 |
+
"""
|
| 80 |
+
users = _load_users()
|
| 81 |
+
if email not in users:
|
| 82 |
+
return False
|
| 83 |
+
users[email]["username"] = username.strip()
|
| 84 |
+
_save_users(users)
|
| 85 |
+
return True
|
backend/email_template.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800;900&display=swap" rel="stylesheet">
|
| 5 |
+
</head>
|
| 6 |
+
<body style="margin: 0; padding: 0; background-color: #ffffff;">
|
| 7 |
+
<div style="font-family: 'Montserrat', sans-serif; color: #333; max-width: 600px; margin: 40px auto; padding: 0 20px;">
|
| 8 |
+
{final_content}
|
| 9 |
+
|
| 10 |
+
<div style="margin-top: 60px; padding-top: 30px; border-top: 1px solid #eee; text-align: center;">
|
| 11 |
+
<p style="font-size: 10px; color: #bbb; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; margin: 0;">Inference Engine Feedback Capture</p>
|
| 12 |
+
<p style="font-size: 9px; color: #ddd; margin-top: 5px;">© 2026 UrbanFlow. All rights reserved.</p>
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
backend/engine.py
CHANGED
|
@@ -9,19 +9,7 @@ from collections import defaultdict
|
|
| 9 |
from pcu import compute_pcu, MODEL_CLASSES
|
| 10 |
from tracker_config import get_tracker_path
|
| 11 |
from speed import estimate_speeds
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def _side(p, a, b):
|
| 15 |
-
return np.sign((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]))
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _point_to_segment_dist(px, py, ax, ay, bx, by):
|
| 19 |
-
A = np.array([ax, ay], dtype=float)
|
| 20 |
-
B = np.array([bx, by], dtype=float)
|
| 21 |
-
P = np.array([px, py], dtype=float)
|
| 22 |
-
AB = B - A
|
| 23 |
-
t = np.clip(np.dot(P - A, AB) / np.dot(AB, AB), 0, 1)
|
| 24 |
-
return np.linalg.norm(P - (A + t * AB))
|
| 25 |
|
| 26 |
|
| 27 |
# Lightweight drawing colors (BGR for OpenCV)
|
|
@@ -160,97 +148,103 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
|
|
| 160 |
batch=2 # MUST match OpenVINO export batch size
|
| 161 |
)
|
| 162 |
|
| 163 |
-
a = line[0]
|
| 164 |
-
b = line[1]
|
|
|
|
| 165 |
|
| 166 |
iterator = iter(enumerate(results))
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
except RuntimeError as e:
|
| 173 |
-
if "incompatible" in str(e) and "shape=" in str(e):
|
| 174 |
-
print(f"[BACKEND] Ignored OpenVINO shape mismatch on final trailing batch.")
|
| 175 |
break
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
if writer is not None:
|
| 228 |
-
|
| 229 |
-
_draw_annotations(frame, cur_boxes, cur_ids, cur_clses, [a, b], annotated_options)
|
| 230 |
-
writer.write(frame)
|
| 231 |
-
|
| 232 |
-
congestion.append(active)
|
| 233 |
-
|
| 234 |
-
elapsed = time.time() - start
|
| 235 |
-
|
| 236 |
-
update = {
|
| 237 |
-
"frame_index": frame_idx + 1,
|
| 238 |
-
"total_iters": total_iters,
|
| 239 |
-
"total_frames": total,
|
| 240 |
-
"active": active,
|
| 241 |
-
"congestion_len": len(congestion), # just the length, not the full list
|
| 242 |
-
"congestion_last": congestion[-1] if congestion else 0, # only latest value
|
| 243 |
-
"class_in": {str(k): v for k, v in class_in.items()},
|
| 244 |
-
"class_out": {str(k): v for k, v in class_out.items()},
|
| 245 |
-
"flow_count": len(flow_times), # just the count
|
| 246 |
-
"elapsed": round(elapsed, 2),
|
| 247 |
-
"fps": round((frame_idx + 1) / elapsed, 2) if elapsed > 0 else 0,
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
on_frame(update)
|
| 251 |
-
|
| 252 |
-
if writer is not None:
|
| 253 |
-
writer.stop()
|
| 254 |
|
| 255 |
processing_time = round(time.time() - start, 2)
|
| 256 |
actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
|
|
|
|
| 9 |
from pcu import compute_pcu, MODEL_CLASSES
|
| 10 |
from tracker_config import get_tracker_path
|
| 11 |
from speed import estimate_speeds
|
| 12 |
+
from geometry import _side, _point_to_segment_dist
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
# Lightweight drawing colors (BGR for OpenCV)
|
|
|
|
| 148 |
batch=2 # MUST match OpenVINO export batch size
|
| 149 |
)
|
| 150 |
|
| 151 |
+
a = line[0] if line and len(line) > 1 else [None, None]
|
| 152 |
+
b = line[1] if line and len(line) > 1 else [None, None]
|
| 153 |
+
valid_line = not (a[0] is None or a[1] is None or b[0] is None or b[1] is None)
|
| 154 |
|
| 155 |
iterator = iter(enumerate(results))
|
| 156 |
+
try:
|
| 157 |
+
while True:
|
| 158 |
+
try:
|
| 159 |
+
frame_idx, r = next(iterator)
|
| 160 |
+
except StopIteration:
|
|
|
|
|
|
|
|
|
|
| 161 |
break
|
| 162 |
+
except RuntimeError as e:
|
| 163 |
+
if "incompatible" in str(e) and "shape=" in str(e):
|
| 164 |
+
print(f"[BACKEND] Ignored OpenVINO shape mismatch on final trailing batch.")
|
| 165 |
+
break
|
| 166 |
+
raise e
|
| 167 |
+
active = 0
|
| 168 |
+
cur_boxes = None
|
| 169 |
+
cur_ids = None
|
| 170 |
+
|
| 171 |
+
if r.boxes.id is not None:
|
| 172 |
+
ids = r.boxes.id.cpu().numpy()
|
| 173 |
+
cls = r.boxes.cls.cpu().numpy()
|
| 174 |
+
xyxy = r.boxes.xyxy.cpu().numpy()
|
| 175 |
+
|
| 176 |
+
active = len(ids)
|
| 177 |
+
confs = r.boxes.conf.cpu().numpy().tolist()
|
| 178 |
+
conf_scores.extend(confs)
|
| 179 |
+
|
| 180 |
+
cur_boxes = xyxy
|
| 181 |
+
cur_ids = ids
|
| 182 |
+
|
| 183 |
+
for obj_id, c, box in zip(ids, cls, xyxy):
|
| 184 |
+
cx = int((box[0] + box[2]) / 2)
|
| 185 |
+
cy = int((box[1] + box[3]) / 2)
|
| 186 |
+
|
| 187 |
+
heatmap_points.append([cx, cy, float(r.boxes.conf.cpu().numpy()[list(ids).index(obj_id)])])
|
| 188 |
+
track_positions[obj_id].append((frame_idx, cx, cy))
|
| 189 |
+
|
| 190 |
+
if not valid_line:
|
| 191 |
+
current = 0
|
| 192 |
+
else:
|
| 193 |
+
current = _side((cx, cy), a, b)
|
| 194 |
+
|
| 195 |
+
# Skip if centroid is exactly on the line (cross-product == 0)
|
| 196 |
+
# — avoids misfired crossings due to floating-point boundary hits
|
| 197 |
+
if current == 0:
|
| 198 |
+
continue
|
| 199 |
+
|
| 200 |
+
if obj_id in prev_side and obj_id not in counted_ids:
|
| 201 |
+
if prev_side[obj_id] != current:
|
| 202 |
+
dist = _point_to_segment_dist(cx, cy, a[0], a[1], b[0], b[1])
|
| 203 |
+
if dist < cross_dist:
|
| 204 |
+
t = frame_idx * stride / fps
|
| 205 |
+
flow_times.append(round(t, 2))
|
| 206 |
+
|
| 207 |
+
if current > 0:
|
| 208 |
+
class_in[int(c)] += 1
|
| 209 |
+
raw_events.append([frame_idx + 1, round(t, 2), int(obj_id), MODEL_CLASSES.get(int(c), f"cls_{int(c)}"), "IN"])
|
| 210 |
+
else:
|
| 211 |
+
class_out[int(c)] += 1
|
| 212 |
+
raw_events.append([frame_idx + 1, round(t, 2), int(obj_id), MODEL_CLASSES.get(int(c), f"cls_{int(c)}"), "OUT"])
|
| 213 |
+
|
| 214 |
+
counted_ids.add(obj_id)
|
| 215 |
+
|
| 216 |
+
prev_side[obj_id] = current
|
| 217 |
+
|
| 218 |
+
# Write annotated frame
|
| 219 |
+
cur_clses = cls if r.boxes.id is not None else None
|
| 220 |
+
if writer is not None:
|
| 221 |
+
frame = r.orig_img.copy()
|
| 222 |
+
_draw_annotations(frame, cur_boxes, cur_ids, cur_clses, [a, b], annotated_options)
|
| 223 |
+
writer.write(frame)
|
| 224 |
+
|
| 225 |
+
congestion.append(active)
|
| 226 |
+
|
| 227 |
+
elapsed = time.time() - start
|
| 228 |
+
|
| 229 |
+
update = {
|
| 230 |
+
"frame_index": frame_idx + 1,
|
| 231 |
+
"total_iters": total_iters,
|
| 232 |
+
"total_frames": total,
|
| 233 |
+
"active": active,
|
| 234 |
+
"congestion_len": len(congestion), # just the length, not the full list
|
| 235 |
+
"congestion_last": congestion[-1] if congestion else 0, # only latest value
|
| 236 |
+
"class_in": {str(k): v for k, v in class_in.items()},
|
| 237 |
+
"class_out": {str(k): v for k, v in class_out.items()},
|
| 238 |
+
"flow_count": len(flow_times), # just the count
|
| 239 |
+
"elapsed": round(elapsed, 2),
|
| 240 |
+
"fps": round((frame_idx + 1) / elapsed, 2) if elapsed > 0 else 0,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
on_frame(update)
|
| 244 |
+
|
| 245 |
+
finally:
|
| 246 |
if writer is not None:
|
| 247 |
+
writer.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
processing_time = round(time.time() - start, 2)
|
| 250 |
actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
|
backend/geometry.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
def _side(p, a, b):
|
| 4 |
+
return np.sign((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]))
|
| 5 |
+
|
| 6 |
+
def _point_to_segment_dist(px, py, ax, ay, bx, by):
|
| 7 |
+
A = np.array([ax, ay], dtype=float)
|
| 8 |
+
B = np.array([bx, by], dtype=float)
|
| 9 |
+
P = np.array([px, py], dtype=float)
|
| 10 |
+
AB = B - A
|
| 11 |
+
t = np.clip(np.dot(P - A, AB) / np.dot(AB, AB), 0, 1)
|
| 12 |
+
return np.linalg.norm(P - (A + t * AB))
|
backend/server.py
CHANGED
|
@@ -8,15 +8,18 @@ from pathlib import Path
|
|
| 8 |
import zipfile
|
| 9 |
|
| 10 |
import cv2
|
| 11 |
-
|
| 12 |
-
from fastapi
|
|
|
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 14 |
import resend
|
| 15 |
|
| 16 |
from model import load_model
|
| 17 |
from config import get_optimal_config
|
| 18 |
from engine import run
|
| 19 |
from pcu import MODEL_CLASSES
|
|
|
|
| 20 |
from visualize import generate_all
|
| 21 |
|
| 22 |
BUSINESS_MAP = {
|
|
@@ -43,6 +46,23 @@ video_info = {}
|
|
| 43 |
run_results = {}
|
| 44 |
model = None
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
@asynccontextmanager
|
| 48 |
async def lifespan(app: FastAPI):
|
|
@@ -62,6 +82,15 @@ async def lifespan(app: FastAPI):
|
|
| 62 |
|
| 63 |
app = FastAPI(lifespan=lifespan)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
@app.get("/")
|
| 67 |
def index():
|
|
@@ -69,12 +98,23 @@ def index():
|
|
| 69 |
|
| 70 |
|
| 71 |
@app.get("/vehicles.html")
|
|
|
|
| 72 |
def vehicles():
|
| 73 |
return FileResponse(FRONTEND / "vehicles.html")
|
| 74 |
|
| 75 |
|
| 76 |
@app.post("/upload")
|
| 77 |
-
async def upload(file: UploadFile = File(...)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
video_id = str(uuid.uuid4())[:8]
|
| 79 |
path = UPLOAD_DIR / f"{video_id}.mp4"
|
| 80 |
|
|
@@ -91,14 +131,20 @@ async def upload(file: UploadFile = File(...)):
|
|
| 91 |
shutil.copyfileobj(file.file, f)
|
| 92 |
|
| 93 |
file_size = os.path.getsize(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
print(f"[BACKEND] Successfully stored: {path} ({file_size} bytes)")
|
| 95 |
|
| 96 |
videos[video_id] = str(path)
|
| 97 |
video_info[video_id] = file.filename
|
|
|
|
|
|
|
| 98 |
return {"video_id": video_id}
|
| 99 |
except Exception as e:
|
| 100 |
print(f"[BACKEND] Upload failed: {str(e)}")
|
| 101 |
-
return
|
| 102 |
|
| 103 |
|
| 104 |
@app.get("/config/{video_id}")
|
|
@@ -142,9 +188,12 @@ def generate_reports(video_id: str):
|
|
| 142 |
|
| 143 |
@app.get("/reports/{video_id}/{name}")
|
| 144 |
def get_report(video_id: str, name: str):
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
| 146 |
if not path.exists():
|
| 147 |
-
return
|
| 148 |
media = "image/png"
|
| 149 |
if name.endswith(".pdf"):
|
| 150 |
media = "application/pdf"
|
|
@@ -164,7 +213,7 @@ def download_all_reports(video_id: str):
|
|
| 164 |
base_path = REPORT_DIR / video_id
|
| 165 |
if not base_path.exists():
|
| 166 |
print(f"[BACKEND] Error: {base_path} not found")
|
| 167 |
-
return
|
| 168 |
|
| 169 |
try:
|
| 170 |
zip_filename = f"bundle_{video_id}.zip"
|
|
@@ -174,12 +223,8 @@ def download_all_reports(video_id: str):
|
|
| 174 |
zip_path.unlink()
|
| 175 |
|
| 176 |
print(f"[BACKEND] Creating ZIP: {zip_path}")
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
for file in files:
|
| 180 |
-
file_path = os.path.join(root, file)
|
| 181 |
-
arcname = os.path.relpath(file_path, base_path)
|
| 182 |
-
zipf.write(file_path, arcname)
|
| 183 |
|
| 184 |
if not zip_path.exists():
|
| 185 |
raise Exception("Zip file was not created")
|
|
@@ -196,7 +241,7 @@ def download_all_reports(video_id: str):
|
|
| 196 |
except Exception as e:
|
| 197 |
import traceback
|
| 198 |
print(f"[BACKEND] ZIP Error: {str(e)}\n{traceback.format_exc()}")
|
| 199 |
-
return
|
| 200 |
|
| 201 |
|
| 202 |
FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
|
|
@@ -269,11 +314,22 @@ def send_feedback_email(api_key, feedback):
|
|
| 269 |
"""
|
| 270 |
|
| 271 |
# Header with Rating
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
header_rating = f"""
|
| 273 |
<div style="text-align: center; margin-bottom: 40px; padding: 20px; background: linear-gradient(180deg, #fff 0%, #fafafa 100%); border-radius: 20px;">
|
| 274 |
<h2 style="color: #8b5e3c; margin: 0; font-size: 26px; font-weight: 900; letter-spacing: -1px;">UrbanFlow Intelligence</h2>
|
| 275 |
<div style="margin-top: 15px; font-size: 22px; color: #c89a6c; letter-spacing: 4px;">{'★' * rating}{'☆' * (5-rating)}</div>
|
| 276 |
<p style="color: #aaa; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; margin-top: 10px; font-weight: 700;">Overall Experience: {rating}/5 Stars</p>
|
|
|
|
| 277 |
</div>
|
| 278 |
"""
|
| 279 |
|
|
@@ -289,45 +345,80 @@ def send_feedback_email(api_key, feedback):
|
|
| 289 |
{detailed_feedback_html}
|
| 290 |
"""
|
| 291 |
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
<head>
|
| 296 |
-
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800;900&display=swap" rel="stylesheet">
|
| 297 |
-
</head>
|
| 298 |
-
<body style="margin: 0; padding: 0; background-color: #ffffff;">
|
| 299 |
-
<div style="font-family: 'Montserrat', sans-serif; color: #333; max-width: 600px; margin: 40px auto; padding: 0 20px;">
|
| 300 |
-
{final_content}
|
| 301 |
-
|
| 302 |
-
<div style="margin-top: 60px; padding-top: 30px; border-top: 1px solid #eee; text-align: center;">
|
| 303 |
-
<p style="font-size: 10px; color: #bbb; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; margin: 0;">Inference Engine Feedback Capture</p>
|
| 304 |
-
<p style="font-size: 9px; color: #ddd; margin-top: 5px;">© 2026 UrbanFlow. All rights reserved.</p>
|
| 305 |
-
</div>
|
| 306 |
-
</div>
|
| 307 |
-
</body>
|
| 308 |
-
</html>
|
| 309 |
-
"""
|
| 310 |
|
| 311 |
resend.Emails.send({
|
| 312 |
"from": "UrbanFlow <onboarding@resend.dev>",
|
| 313 |
"to": "support.urbanflow365@gmail.com",
|
| 314 |
-
"subject": f"Feedback: {fb_type} - {rating}/5 Stars",
|
| 315 |
"html": html_body
|
| 316 |
})
|
| 317 |
print(f"[BACKEND] Feedback email successfully transmitted via Resend.")
|
| 318 |
except Exception as e:
|
| 319 |
print(f"[BACKEND] Resend Error: {str(e)}")
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
@app.post("/api/feedback")
|
| 322 |
async def submit_feedback(background_tasks: BackgroundTasks, request_data: dict = None):
|
| 323 |
from datetime import datetime, timezone
|
| 324 |
feedback = request_data or {}
|
| 325 |
-
entries = []
|
| 326 |
-
if FEEDBACK_PATH.exists():
|
| 327 |
-
entries = json.loads(FEEDBACK_PATH.read_text())
|
| 328 |
feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
# Trigger Email via Resend if API key is present
|
| 333 |
resend_key = os.getenv("RESEND_API_KEY")
|
|
@@ -395,6 +486,7 @@ async def ws_run(ws: WebSocket):
|
|
| 395 |
result["export_json"] = data.get("export_json", False)
|
| 396 |
result["export_csv"] = data.get("export_csv", False)
|
| 397 |
run_results[video_id] = result
|
|
|
|
| 398 |
await ws.send_text(json.dumps({
|
| 399 |
"done": True,
|
| 400 |
"video_id": video_id,
|
|
|
|
| 8 |
import zipfile
|
| 9 |
|
| 10 |
import cv2
|
| 11 |
+
import time
|
| 12 |
+
from fastapi import FastAPI, WebSocket, UploadFile, File, BackgroundTasks, Request
|
| 13 |
+
from fastapi.responses import FileResponse, Response, JSONResponse
|
| 14 |
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 16 |
import resend
|
| 17 |
|
| 18 |
from model import load_model
|
| 19 |
from config import get_optimal_config
|
| 20 |
from engine import run
|
| 21 |
from pcu import MODEL_CLASSES
|
| 22 |
+
from auth import verify_google_token, get_or_create_user, set_username, GOOGLE_CLIENT_ID
|
| 23 |
from visualize import generate_all
|
| 24 |
|
| 25 |
BUSINESS_MAP = {
|
|
|
|
| 46 |
run_results = {}
|
| 47 |
model = None
|
| 48 |
|
| 49 |
+
MAX_MEMORY_ENTRIES = 50
|
| 50 |
+
def evict_old(d):
|
| 51 |
+
while len(d) > MAX_MEMORY_ENTRIES:
|
| 52 |
+
d.pop(next(iter(d)))
|
| 53 |
+
|
| 54 |
+
UPLOAD_LIMITS = {}
|
| 55 |
+
def is_rate_limited(ip: str):
|
| 56 |
+
now = time.time()
|
| 57 |
+
stamps = [t for t in UPLOAD_LIMITS.get(ip, []) if now - t < 60]
|
| 58 |
+
if len(stamps) >= 5:
|
| 59 |
+
UPLOAD_LIMITS[ip] = stamps
|
| 60 |
+
return True
|
| 61 |
+
stamps.append(now)
|
| 62 |
+
UPLOAD_LIMITS[ip] = stamps
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
MAX_UPLOAD_BYTES = 500 * 1024 * 1024
|
| 66 |
|
| 67 |
@asynccontextmanager
|
| 68 |
async def lifespan(app: FastAPI):
|
|
|
|
| 82 |
|
| 83 |
app = FastAPI(lifespan=lifespan)
|
| 84 |
|
| 85 |
+
class SecurityHeaders(BaseHTTPMiddleware):
|
| 86 |
+
async def dispatch(self, request, call_next):
|
| 87 |
+
response = await call_next(request)
|
| 88 |
+
# response.headers["X-Frame-Options"] = "SAMEORIGIN" # Removed for HF Spaces compatibility
|
| 89 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 90 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 91 |
+
return response
|
| 92 |
+
|
| 93 |
+
app.add_middleware(SecurityHeaders)
|
| 94 |
|
| 95 |
@app.get("/")
|
| 96 |
def index():
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
@app.get("/vehicles.html")
|
| 101 |
+
@app.get("/vehicles")
|
| 102 |
def vehicles():
|
| 103 |
return FileResponse(FRONTEND / "vehicles.html")
|
| 104 |
|
| 105 |
|
| 106 |
@app.post("/upload")
|
| 107 |
+
async def upload(request: Request, file: UploadFile = File(...)):
|
| 108 |
+
client_ip = request.client.host if request.client else "unknown"
|
| 109 |
+
if is_rate_limited(client_ip):
|
| 110 |
+
return JSONResponse({"error": "Rate limit exceeded. Please wait a minute."}, status_code=429)
|
| 111 |
+
|
| 112 |
+
if not file.content_type.startswith("video/"):
|
| 113 |
+
return JSONResponse({"error": "Invalid file type. Only videos are allowed."}, status_code=400)
|
| 114 |
+
|
| 115 |
+
if hasattr(file, 'size') and file.size and file.size > MAX_UPLOAD_BYTES:
|
| 116 |
+
return JSONResponse({"error": "File too large. Maximum size is 500MB."}, status_code=413)
|
| 117 |
+
|
| 118 |
video_id = str(uuid.uuid4())[:8]
|
| 119 |
path = UPLOAD_DIR / f"{video_id}.mp4"
|
| 120 |
|
|
|
|
| 131 |
shutil.copyfileobj(file.file, f)
|
| 132 |
|
| 133 |
file_size = os.path.getsize(path)
|
| 134 |
+
if file_size > MAX_UPLOAD_BYTES:
|
| 135 |
+
path.unlink()
|
| 136 |
+
return JSONResponse({"error": "File too large. Maximum size is 500MB."}, status_code=413)
|
| 137 |
+
|
| 138 |
print(f"[BACKEND] Successfully stored: {path} ({file_size} bytes)")
|
| 139 |
|
| 140 |
videos[video_id] = str(path)
|
| 141 |
video_info[video_id] = file.filename
|
| 142 |
+
evict_old(videos)
|
| 143 |
+
evict_old(video_info)
|
| 144 |
return {"video_id": video_id}
|
| 145 |
except Exception as e:
|
| 146 |
print(f"[BACKEND] Upload failed: {str(e)}")
|
| 147 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 148 |
|
| 149 |
|
| 150 |
@app.get("/config/{video_id}")
|
|
|
|
| 188 |
|
| 189 |
@app.get("/reports/{video_id}/{name}")
|
| 190 |
def get_report(video_id: str, name: str):
|
| 191 |
+
safe_name = Path(name).name
|
| 192 |
+
path = REPORT_DIR / video_id / safe_name
|
| 193 |
+
if not path.resolve().is_relative_to(REPORT_DIR.resolve()):
|
| 194 |
+
return JSONResponse({"error": "Invalid path"}, status_code=400)
|
| 195 |
if not path.exists():
|
| 196 |
+
return JSONResponse({"error": "File not found"}, status_code=404)
|
| 197 |
media = "image/png"
|
| 198 |
if name.endswith(".pdf"):
|
| 199 |
media = "application/pdf"
|
|
|
|
| 213 |
base_path = REPORT_DIR / video_id
|
| 214 |
if not base_path.exists():
|
| 215 |
print(f"[BACKEND] Error: {base_path} not found")
|
| 216 |
+
return JSONResponse({"error": f"Report directory not found for {video_id}"}, status_code=404)
|
| 217 |
|
| 218 |
try:
|
| 219 |
zip_filename = f"bundle_{video_id}.zip"
|
|
|
|
| 223 |
zip_path.unlink()
|
| 224 |
|
| 225 |
print(f"[BACKEND] Creating ZIP: {zip_path}")
|
| 226 |
+
# shutil.make_archive adds the .zip extension, so we strip it from the target path
|
| 227 |
+
shutil.make_archive(str(REPORT_DIR / f"bundle_{video_id}"), 'zip', str(base_path))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
if not zip_path.exists():
|
| 230 |
raise Exception("Zip file was not created")
|
|
|
|
| 241 |
except Exception as e:
|
| 242 |
import traceback
|
| 243 |
print(f"[BACKEND] ZIP Error: {str(e)}\n{traceback.format_exc()}")
|
| 244 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 245 |
|
| 246 |
|
| 247 |
FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
|
|
|
|
| 314 |
"""
|
| 315 |
|
| 316 |
# Header with Rating
|
| 317 |
+
user_email = feedback.get('user_email', '')
|
| 318 |
+
user_email_html = ''
|
| 319 |
+
if user_email:
|
| 320 |
+
user_email_html = f"""
|
| 321 |
+
<div style="margin-top: 14px; padding: 10px 20px; background: #fff; border: 1px solid #eee; border-radius: 10px; display: inline-block;">
|
| 322 |
+
<span style="font-size: 9px; font-weight: 800; color: #999; text-transform: uppercase; letter-spacing: 1px;">Submitted by</span><br>
|
| 323 |
+
<a href="mailto:{user_email}" style="color: #8b5e3c; font-size: 13px; font-weight: 700; text-decoration: none;">{user_email}</a>
|
| 324 |
+
</div>
|
| 325 |
+
"""
|
| 326 |
+
|
| 327 |
header_rating = f"""
|
| 328 |
<div style="text-align: center; margin-bottom: 40px; padding: 20px; background: linear-gradient(180deg, #fff 0%, #fafafa 100%); border-radius: 20px;">
|
| 329 |
<h2 style="color: #8b5e3c; margin: 0; font-size: 26px; font-weight: 900; letter-spacing: -1px;">UrbanFlow Intelligence</h2>
|
| 330 |
<div style="margin-top: 15px; font-size: 22px; color: #c89a6c; letter-spacing: 4px;">{'★' * rating}{'☆' * (5-rating)}</div>
|
| 331 |
<p style="color: #aaa; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; margin-top: 10px; font-weight: 700;">Overall Experience: {rating}/5 Stars</p>
|
| 332 |
+
{user_email_html}
|
| 333 |
</div>
|
| 334 |
"""
|
| 335 |
|
|
|
|
| 345 |
{detailed_feedback_html}
|
| 346 |
"""
|
| 347 |
|
| 348 |
+
template_path = BASE / "backend" / "email_template.html"
|
| 349 |
+
html_template = template_path.read_text(encoding="utf-8") if template_path.exists() else "{final_content}"
|
| 350 |
+
html_body = html_template.format(final_content=final_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
resend.Emails.send({
|
| 353 |
"from": "UrbanFlow <onboarding@resend.dev>",
|
| 354 |
"to": "support.urbanflow365@gmail.com",
|
| 355 |
+
"subject": f"Feedback: {fb_type} - {rating}/5 Stars" + (f" [{feedback.get('user_email', '')}]" if feedback.get('user_email') else ""),
|
| 356 |
"html": html_body
|
| 357 |
})
|
| 358 |
print(f"[BACKEND] Feedback email successfully transmitted via Resend.")
|
| 359 |
except Exception as e:
|
| 360 |
print(f"[BACKEND] Resend Error: {str(e)}")
|
| 361 |
|
| 362 |
+
# =========== Auth Endpoints ===========
|
| 363 |
+
|
| 364 |
+
@app.get("/api/auth/client-id")
|
| 365 |
+
def auth_client_id():
|
| 366 |
+
if not GOOGLE_CLIENT_ID:
|
| 367 |
+
return JSONResponse({"error": "GOOGLE_CLIENT_ID not configured"}, status_code=500)
|
| 368 |
+
return {"client_id": GOOGLE_CLIENT_ID}
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@app.post("/api/auth/verify")
|
| 372 |
+
async def auth_verify(request_data: dict):
|
| 373 |
+
credential = request_data.get("credential", "")
|
| 374 |
+
if not credential:
|
| 375 |
+
return JSONResponse({"error": "Missing credential"}, status_code=400)
|
| 376 |
+
try:
|
| 377 |
+
token_info = verify_google_token(credential)
|
| 378 |
+
except ValueError as e:
|
| 379 |
+
return JSONResponse({"error": str(e)}, status_code=401)
|
| 380 |
+
user = get_or_create_user(token_info["email"], token_info["name"], token_info["picture"])
|
| 381 |
+
return user
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
@app.post("/api/auth/onboard")
|
| 385 |
+
async def auth_onboard(request_data: dict):
|
| 386 |
+
email = request_data.get("email", "")
|
| 387 |
+
username = request_data.get("username", "")
|
| 388 |
+
if not email or not username:
|
| 389 |
+
return JSONResponse({"error": "Email and username required"}, status_code=400)
|
| 390 |
+
ok = set_username(email, username)
|
| 391 |
+
if not ok:
|
| 392 |
+
return JSONResponse({"error": "User not found"}, status_code=404)
|
| 393 |
+
return {"status": "ok"}
|
| 394 |
+
|
| 395 |
+
@app.post("/api/event")
|
| 396 |
+
async def track_event(request_data: dict):
|
| 397 |
+
event = request_data.get("event", "UNKNOWN")
|
| 398 |
+
meta = request_data.get("meta", {})
|
| 399 |
+
print(f"[ANALYTICS] EVENT: {event} | {meta}")
|
| 400 |
+
return {"status": "ok"}
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# =========== Feedback ===========
|
| 404 |
+
|
| 405 |
@app.post("/api/feedback")
|
| 406 |
async def submit_feedback(background_tasks: BackgroundTasks, request_data: dict = None):
|
| 407 |
from datetime import datetime, timezone
|
| 408 |
feedback = request_data or {}
|
|
|
|
|
|
|
|
|
|
| 409 |
feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
|
| 410 |
+
|
| 411 |
+
def write_feedback(fb):
|
| 412 |
+
entries = []
|
| 413 |
+
if FEEDBACK_PATH.exists():
|
| 414 |
+
try:
|
| 415 |
+
entries = json.loads(FEEDBACK_PATH.read_text())
|
| 416 |
+
except Exception:
|
| 417 |
+
pass
|
| 418 |
+
entries.append(fb)
|
| 419 |
+
FEEDBACK_PATH.write_text(json.dumps(entries, indent=2))
|
| 420 |
+
|
| 421 |
+
background_tasks.add_task(write_feedback, feedback)
|
| 422 |
|
| 423 |
# Trigger Email via Resend if API key is present
|
| 424 |
resend_key = os.getenv("RESEND_API_KEY")
|
|
|
|
| 486 |
result["export_json"] = data.get("export_json", False)
|
| 487 |
result["export_csv"] = data.get("export_csv", False)
|
| 488 |
run_results[video_id] = result
|
| 489 |
+
evict_old(run_results)
|
| 490 |
await ws.send_text(json.dumps({
|
| 491 |
"done": True,
|
| 492 |
"video_id": video_id,
|
frontend/css/auth.css
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================
|
| 2 |
+
UrbanFlow — auth.css
|
| 3 |
+
Styles for auth overlay, onboarding, profile,
|
| 4 |
+
and logout confirmation. Matches the existing
|
| 5 |
+
design system (dark theme, #c89a6c accent).
|
| 6 |
+
============================================ */
|
| 7 |
+
|
| 8 |
+
/* ---- Auth Overlay (fullscreen backdrop) ---- */
|
| 9 |
+
.auth-overlay {
|
| 10 |
+
display: none;
|
| 11 |
+
position: fixed;
|
| 12 |
+
inset: 0;
|
| 13 |
+
background: rgba(0, 0, 0, 0.88);
|
| 14 |
+
z-index: 10000;
|
| 15 |
+
align-items: center;
|
| 16 |
+
justify-content: center;
|
| 17 |
+
padding: 24px;
|
| 18 |
+
backdrop-filter: blur(6px);
|
| 19 |
+
-webkit-backdrop-filter: blur(6px);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.auth-card {
|
| 23 |
+
background: #0a0a0a;
|
| 24 |
+
border: 1px solid #2a2a2a;
|
| 25 |
+
border-radius: 18px;
|
| 26 |
+
max-width: 400px;
|
| 27 |
+
width: 100%;
|
| 28 |
+
padding: 40px 32px;
|
| 29 |
+
text-align: center;
|
| 30 |
+
animation: authFadeIn 0.3s ease;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@keyframes authFadeIn {
|
| 34 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 35 |
+
to { opacity: 1; transform: translateY(0); }
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.auth-card-header {
|
| 39 |
+
margin-bottom: 28px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.auth-title {
|
| 43 |
+
color: #f0ece6;
|
| 44 |
+
font-size: 1.4rem;
|
| 45 |
+
font-weight: 800;
|
| 46 |
+
font-family: 'Montserrat', sans-serif;
|
| 47 |
+
margin: 0 0 6px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.auth-subtitle {
|
| 51 |
+
color: #777;
|
| 52 |
+
font-size: 12px;
|
| 53 |
+
font-weight: 500;
|
| 54 |
+
margin: 0;
|
| 55 |
+
line-height: 1.6;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.auth-google-btn {
|
| 59 |
+
display: flex;
|
| 60 |
+
justify-content: center;
|
| 61 |
+
margin: 24px 0 16px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.auth-error {
|
| 65 |
+
color: #ef4444;
|
| 66 |
+
font-size: 11px;
|
| 67 |
+
font-weight: 600;
|
| 68 |
+
margin-top: 12px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.auth-footer {
|
| 72 |
+
color: #555;
|
| 73 |
+
font-size: 10px;
|
| 74 |
+
margin-top: 20px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.auth-link {
|
| 78 |
+
color: #c89a6c;
|
| 79 |
+
background: none;
|
| 80 |
+
border: none;
|
| 81 |
+
font-weight: 700;
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
text-decoration: underline;
|
| 84 |
+
text-underline-offset: 3px;
|
| 85 |
+
font-size: inherit;
|
| 86 |
+
padding: 0;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.auth-link:hover {
|
| 90 |
+
color: #f0ece6;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* ---- Onboarding Form ---- */
|
| 94 |
+
.auth-avatar {
|
| 95 |
+
width: 56px;
|
| 96 |
+
height: 56px;
|
| 97 |
+
border-radius: 50%;
|
| 98 |
+
margin: 0 auto 16px;
|
| 99 |
+
border: 2px solid #2a2a2a;
|
| 100 |
+
display: block;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.auth-label {
|
| 104 |
+
display: block;
|
| 105 |
+
text-align: left;
|
| 106 |
+
color: #a89f97;
|
| 107 |
+
font-size: 10px;
|
| 108 |
+
font-weight: 800;
|
| 109 |
+
text-transform: uppercase;
|
| 110 |
+
letter-spacing: 1px;
|
| 111 |
+
margin-bottom: 8px;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.auth-input {
|
| 115 |
+
width: 100%;
|
| 116 |
+
background: #111;
|
| 117 |
+
border: 1px solid #2a2a2a;
|
| 118 |
+
border-radius: 10px;
|
| 119 |
+
padding: 12px 16px;
|
| 120 |
+
color: #f0ece6;
|
| 121 |
+
font-family: 'Montserrat', sans-serif;
|
| 122 |
+
font-size: 14px;
|
| 123 |
+
font-weight: 600;
|
| 124 |
+
outline: none;
|
| 125 |
+
transition: border-color 0.2s;
|
| 126 |
+
box-sizing: border-box;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.auth-input:focus {
|
| 130 |
+
border-color: #c89a6c;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.auth-input::placeholder {
|
| 134 |
+
color: #555;
|
| 135 |
+
font-weight: 400;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.auth-onboard-form {
|
| 139 |
+
text-align: left;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.auth-submit-btn {
|
| 143 |
+
width: 100%;
|
| 144 |
+
margin-top: 18px;
|
| 145 |
+
padding: 12px 24px;
|
| 146 |
+
background: #c89a6c;
|
| 147 |
+
color: #000;
|
| 148 |
+
border: none;
|
| 149 |
+
border-radius: 10px;
|
| 150 |
+
font-family: 'Montserrat', sans-serif;
|
| 151 |
+
font-size: 13px;
|
| 152 |
+
font-weight: 800;
|
| 153 |
+
text-transform: uppercase;
|
| 154 |
+
letter-spacing: 1px;
|
| 155 |
+
cursor: pointer;
|
| 156 |
+
transition: opacity 0.2s, transform 0.1s;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.auth-submit-btn:hover {
|
| 160 |
+
opacity: 0.9;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.auth-submit-btn:active {
|
| 164 |
+
transform: scale(0.97);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.auth-submit-btn:disabled {
|
| 168 |
+
opacity: 0.5;
|
| 169 |
+
cursor: not-allowed;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.auth-cancel-btn {
|
| 173 |
+
padding: 10px 24px;
|
| 174 |
+
background: transparent;
|
| 175 |
+
color: #a89f97;
|
| 176 |
+
border: 1px solid #2a2a2a;
|
| 177 |
+
border-radius: 10px;
|
| 178 |
+
font-family: 'Montserrat', sans-serif;
|
| 179 |
+
font-size: 12px;
|
| 180 |
+
font-weight: 700;
|
| 181 |
+
cursor: pointer;
|
| 182 |
+
transition: border-color 0.2s, color 0.2s;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.auth-cancel-btn:hover {
|
| 186 |
+
border-color: #555;
|
| 187 |
+
color: #f0ece6;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/* ---- Profile Section (Desktop Sidebar) ---- */
|
| 191 |
+
.sidebar-profile {
|
| 192 |
+
display: flex;
|
| 193 |
+
align-items: center;
|
| 194 |
+
gap: 10px;
|
| 195 |
+
padding: 12px 16px;
|
| 196 |
+
border-bottom: 1px solid #1a1a1a;
|
| 197 |
+
margin-bottom: 8px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.sidebar-profile-pic {
|
| 201 |
+
width: 32px;
|
| 202 |
+
height: 32px;
|
| 203 |
+
border-radius: 50%;
|
| 204 |
+
border: 1.5px solid #2a2a2a;
|
| 205 |
+
flex-shrink: 0;
|
| 206 |
+
object-fit: cover;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.sidebar-profile-info {
|
| 210 |
+
flex: 1;
|
| 211 |
+
min-width: 0;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.sidebar-profile-name {
|
| 215 |
+
color: #f0ece6;
|
| 216 |
+
font-size: 12px;
|
| 217 |
+
font-weight: 700;
|
| 218 |
+
white-space: nowrap;
|
| 219 |
+
overflow: hidden;
|
| 220 |
+
text-overflow: ellipsis;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.sidebar-profile-email {
|
| 224 |
+
color: #666;
|
| 225 |
+
font-size: 9px;
|
| 226 |
+
font-weight: 500;
|
| 227 |
+
white-space: nowrap;
|
| 228 |
+
overflow: hidden;
|
| 229 |
+
text-overflow: ellipsis;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.sidebar-signout-btn {
|
| 233 |
+
background: none;
|
| 234 |
+
border: none;
|
| 235 |
+
color: #555;
|
| 236 |
+
font-size: 14px;
|
| 237 |
+
cursor: pointer;
|
| 238 |
+
padding: 4px;
|
| 239 |
+
flex-shrink: 0;
|
| 240 |
+
transition: color 0.2s;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.sidebar-signout-btn:hover {
|
| 244 |
+
color: #ef4444;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* ---- Profile in Mobile Three-Dot Menu ---- */
|
| 248 |
+
.mobile-profile-row {
|
| 249 |
+
display: flex;
|
| 250 |
+
align-items: center;
|
| 251 |
+
gap: 10px;
|
| 252 |
+
padding: 10px 16px;
|
| 253 |
+
border-bottom: 1px solid #1a1a1a;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.mobile-profile-pic {
|
| 257 |
+
width: 32px;
|
| 258 |
+
height: 32px;
|
| 259 |
+
border-radius: 50%;
|
| 260 |
+
border: 1px solid #2a2a2a;
|
| 261 |
+
flex-shrink: 0;
|
| 262 |
+
object-fit: cover;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.mobile-profile-name {
|
| 266 |
+
color: #f0ece6;
|
| 267 |
+
font-size: 11px;
|
| 268 |
+
font-weight: 700;
|
| 269 |
+
white-space: nowrap;
|
| 270 |
+
overflow: hidden;
|
| 271 |
+
text-overflow: ellipsis;
|
| 272 |
+
flex: 1;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.mobile-signout-btn {
|
| 276 |
+
width: 100%;
|
| 277 |
+
text-align: left;
|
| 278 |
+
padding: 10px 16px;
|
| 279 |
+
color: #ef4444;
|
| 280 |
+
font-size: 10px;
|
| 281 |
+
font-weight: 800;
|
| 282 |
+
text-transform: uppercase;
|
| 283 |
+
letter-spacing: 1px;
|
| 284 |
+
background: none;
|
| 285 |
+
border: none;
|
| 286 |
+
border-top: 1px solid #1a1a1a;
|
| 287 |
+
cursor: pointer;
|
| 288 |
+
transition: background 0.2s;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.mobile-signout-btn:hover {
|
| 292 |
+
background: #111;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/* ---- Custom "Continue with Google" Button ---- */
|
| 296 |
+
.gsi-custom-btn {
|
| 297 |
+
display: inline-flex;
|
| 298 |
+
align-items: center;
|
| 299 |
+
gap: 12px;
|
| 300 |
+
background: #1a1a1a;
|
| 301 |
+
border: 1px solid #333;
|
| 302 |
+
border-radius: 24px;
|
| 303 |
+
padding: 12px 28px;
|
| 304 |
+
color: #f0ece6;
|
| 305 |
+
font-family: 'Montserrat', sans-serif;
|
| 306 |
+
font-size: 13px;
|
| 307 |
+
font-weight: 700;
|
| 308 |
+
cursor: pointer;
|
| 309 |
+
transition: background 0.2s, border-color 0.2s, transform 0.1s;
|
| 310 |
+
white-space: nowrap;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.gsi-custom-btn:hover {
|
| 314 |
+
background: #222;
|
| 315 |
+
border-color: #555;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.gsi-custom-btn:active {
|
| 319 |
+
transform: scale(0.97);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* ---- Utility ---- */
|
| 323 |
+
.hidden { display: none !important; }
|
frontend/css/initial.css
CHANGED
|
@@ -29,10 +29,10 @@ body {
|
|
| 29 |
color: var(--t1);
|
| 30 |
margin: 0;
|
| 31 |
-webkit-tap-highlight-color: transparent;
|
| 32 |
-
scrollbar-width: none;
|
| 33 |
}
|
| 34 |
body::-webkit-scrollbar {
|
| 35 |
-
display: none;
|
| 36 |
}
|
| 37 |
|
| 38 |
/* ---- Fade animation ---- */
|
|
@@ -44,20 +44,27 @@ body::-webkit-scrollbar {
|
|
| 44 |
to { opacity: 1; transform: translateY(0); }
|
| 45 |
}
|
| 46 |
|
| 47 |
-
/* ----
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
-
.hero-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
-
.hero-text-section ul li:nth-child(1) { animation-delay: 0.1s; }
|
| 57 |
-
.hero-text-section ul li:nth-child(2) { animation-delay: 0.2s; }
|
| 58 |
-
.hero-text-section ul li:nth-child(3) { animation-delay: 0.3s; }
|
| 59 |
-
.hero-text-section ul li:nth-child(4) { animation-delay: 0.4s; }
|
| 60 |
-
.hero-text-section ul li:nth-child(5) { animation-delay: 0.5s; }
|
| 61 |
|
| 62 |
/* ---- Card ---- */
|
| 63 |
.traffic-dynamics-card {
|
|
@@ -93,8 +100,8 @@ body::-webkit-scrollbar {
|
|
| 93 |
pointer-events: none;
|
| 94 |
}
|
| 95 |
@keyframes borderShimmer {
|
| 96 |
-
0% { background-position:
|
| 97 |
-
100% { background-position:
|
| 98 |
}
|
| 99 |
.traffic-dynamics-card:hover::before {
|
| 100 |
animation-duration: 1.2s;
|
|
@@ -184,19 +191,23 @@ body::-webkit-scrollbar {
|
|
| 184 |
#upload-bar {
|
| 185 |
transition: width 0.3s ease-out;
|
| 186 |
position: relative;
|
|
|
|
| 187 |
}
|
| 188 |
|
| 189 |
/* Shimmer overlay */
|
| 190 |
#upload-bar::after {
|
| 191 |
content: '';
|
| 192 |
position: absolute;
|
| 193 |
-
top: 0;
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
@keyframes shimmer {
|
| 199 |
-
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
/* =============================================
|
|
@@ -285,6 +296,7 @@ body::-webkit-scrollbar {
|
|
| 285 |
overflow-x: hidden !important;
|
| 286 |
}
|
| 287 |
|
|
|
|
| 288 |
/* ---- Header — compact logo ---- */
|
| 289 |
header {
|
| 290 |
margin-top: 2rem !important;
|
|
@@ -411,19 +423,21 @@ body::-webkit-scrollbar {
|
|
| 411 |
max-height: 40vh; /* Keeps continue button above fold */
|
| 412 |
}
|
| 413 |
|
| 414 |
-
/* ---- Proceed button —
|
| 415 |
#btn-proceed {
|
| 416 |
-
width:
|
|
|
|
| 417 |
padding: 14px !important;
|
| 418 |
font-size: 0.9rem !important;
|
| 419 |
border-radius: 999px !important;
|
| 420 |
}
|
| 421 |
|
| 422 |
-
/* ---- Reset / Back buttons ---- */
|
| 423 |
button[onclick="resetCanvas()"],
|
| 424 |
button[onclick="showStep('modules')"] {
|
| 425 |
font-size: 11px !important;
|
| 426 |
-
padding: 10px
|
|
|
|
| 427 |
min-height: 40px;
|
| 428 |
}
|
| 429 |
|
|
@@ -446,6 +460,8 @@ body::-webkit-scrollbar {
|
|
| 446 |
}
|
| 447 |
|
| 448 |
|
|
|
|
|
|
|
| 449 |
/* =============================================
|
| 450 |
TOUCH: remove drag-related cursor on mobile
|
| 451 |
============================================= */
|
|
@@ -473,4 +489,4 @@ body::-webkit-scrollbar {
|
|
| 473 |
#drawing-canvas {
|
| 474 |
cursor: crosshair;
|
| 475 |
}
|
| 476 |
-
}
|
|
|
|
| 29 |
color: var(--t1);
|
| 30 |
margin: 0;
|
| 31 |
-webkit-tap-highlight-color: transparent;
|
| 32 |
+
scrollbar-width: none;
|
| 33 |
}
|
| 34 |
body::-webkit-scrollbar {
|
| 35 |
+
display: none;
|
| 36 |
}
|
| 37 |
|
| 38 |
/* ---- Fade animation ---- */
|
|
|
|
| 44 |
to { opacity: 1; transform: translateY(0); }
|
| 45 |
}
|
| 46 |
|
| 47 |
+
/* ---- Hero Description Block ---- */
|
| 48 |
+
.hero-desc {
|
| 49 |
+
max-width: 520px;
|
| 50 |
+
}
|
| 51 |
+
.hero-desc-lead {
|
| 52 |
+
font-size: clamp(1rem, 1.8vw, 1.15rem);
|
| 53 |
+
font-weight: 700;
|
| 54 |
+
letter-spacing: 0.01em;
|
| 55 |
+
line-height: 1.4;
|
| 56 |
}
|
| 57 |
+
.hero-desc-body {
|
| 58 |
+
font-size: clamp(0.82rem, 1.3vw, 0.95rem);
|
| 59 |
+
font-weight: 500;
|
| 60 |
+
line-height: 1.8;
|
| 61 |
+
letter-spacing: 0.01em;
|
| 62 |
+
}
|
| 63 |
+
@media (max-width: 640px) {
|
| 64 |
+
.hero-desc { max-width: 100%; }
|
| 65 |
+
.hero-desc-lead { font-size: 1rem; }
|
| 66 |
+
.hero-desc-body { font-size: 0.85rem; line-height: 1.75; }
|
| 67 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
/* ---- Card ---- */
|
| 70 |
.traffic-dynamics-card {
|
|
|
|
| 100 |
pointer-events: none;
|
| 101 |
}
|
| 102 |
@keyframes borderShimmer {
|
| 103 |
+
0% { background-position: 0% 50%; }
|
| 104 |
+
100% { background-position: 200% 50%; }
|
| 105 |
}
|
| 106 |
.traffic-dynamics-card:hover::before {
|
| 107 |
animation-duration: 1.2s;
|
|
|
|
| 191 |
#upload-bar {
|
| 192 |
transition: width 0.3s ease-out;
|
| 193 |
position: relative;
|
| 194 |
+
overflow: hidden;
|
| 195 |
}
|
| 196 |
|
| 197 |
/* Shimmer overlay */
|
| 198 |
#upload-bar::after {
|
| 199 |
content: '';
|
| 200 |
position: absolute;
|
| 201 |
+
top: 0;
|
| 202 |
+
bottom: 0;
|
| 203 |
+
left: -60%;
|
| 204 |
+
width: 60%;
|
| 205 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
|
| 206 |
+
animation: shimmer 1.4s ease-in-out infinite;
|
| 207 |
}
|
| 208 |
@keyframes shimmer {
|
| 209 |
+
0% { left: -60%; }
|
| 210 |
+
100% { left: 160%; }
|
| 211 |
}
|
| 212 |
|
| 213 |
/* =============================================
|
|
|
|
| 296 |
overflow-x: hidden !important;
|
| 297 |
}
|
| 298 |
|
| 299 |
+
|
| 300 |
/* ---- Header — compact logo ---- */
|
| 301 |
header {
|
| 302 |
margin-top: 2rem !important;
|
|
|
|
| 423 |
max-height: 40vh; /* Keeps continue button above fold */
|
| 424 |
}
|
| 425 |
|
| 426 |
+
/* ---- Proceed button — narrowed further ---- */
|
| 427 |
#btn-proceed {
|
| 428 |
+
width: 60% !important;
|
| 429 |
+
max-width: 220px;
|
| 430 |
padding: 14px !important;
|
| 431 |
font-size: 0.9rem !important;
|
| 432 |
border-radius: 999px !important;
|
| 433 |
}
|
| 434 |
|
| 435 |
+
/* ---- Reset / Back buttons — fixed padding for rounded style ---- */
|
| 436 |
button[onclick="resetCanvas()"],
|
| 437 |
button[onclick="showStep('modules')"] {
|
| 438 |
font-size: 11px !important;
|
| 439 |
+
padding: 10px 24px !important;
|
| 440 |
+
width: auto !important;
|
| 441 |
min-height: 40px;
|
| 442 |
}
|
| 443 |
|
|
|
|
| 460 |
}
|
| 461 |
|
| 462 |
|
| 463 |
+
|
| 464 |
+
|
| 465 |
/* =============================================
|
| 466 |
TOUCH: remove drag-related cursor on mobile
|
| 467 |
============================================= */
|
|
|
|
| 489 |
#drawing-canvas {
|
| 490 |
cursor: crosshair;
|
| 491 |
}
|
| 492 |
+
}
|
frontend/css/shared.css
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================
|
| 2 |
+
UrbanFlow — shared.css
|
| 3 |
+
Reusable component classes shared across all pages.
|
| 4 |
+
Single source of truth for the design system.
|
| 5 |
+
============================================= */
|
| 6 |
+
|
| 7 |
+
/* ---- Typography Colors ---- */
|
| 8 |
+
.text-accent { color: #c89a6c !important; }
|
| 9 |
+
.text-primary { color: #f0ece6 !important; }
|
| 10 |
+
.text-secondary { color: #a89f97 !important; }
|
| 11 |
+
.text-muted { color: #555 !important; }
|
| 12 |
+
.text-dim { color: #444 !important; }
|
| 13 |
+
.text-cocoa { color: #8b5e3c !important; }
|
| 14 |
+
.bg-accent { background-color: #c89a6c !important; }
|
| 15 |
+
|
| 16 |
+
/* ---- Modal Overlay ---- */
|
| 17 |
+
.modal-overlay {
|
| 18 |
+
display: none;
|
| 19 |
+
position: fixed;
|
| 20 |
+
inset: 0;
|
| 21 |
+
background: rgba(0, 0, 0, 0.85);
|
| 22 |
+
z-index: 10001;
|
| 23 |
+
align-items: center;
|
| 24 |
+
justify-content: center;
|
| 25 |
+
padding: 24px;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* ---- Modal Card ---- */
|
| 29 |
+
.modal-card {
|
| 30 |
+
background: #0a0a0a;
|
| 31 |
+
border: 1px solid #2a2a2a;
|
| 32 |
+
border-radius: 14px;
|
| 33 |
+
max-width: 480px;
|
| 34 |
+
width: 100%;
|
| 35 |
+
padding: 32px;
|
| 36 |
+
position: relative;
|
| 37 |
+
max-height: 80vh;
|
| 38 |
+
overflow-y: auto;
|
| 39 |
+
}
|
| 40 |
+
.modal-card-sm {
|
| 41 |
+
max-width: 380px;
|
| 42 |
+
padding: 28px;
|
| 43 |
+
max-height: none;
|
| 44 |
+
overflow-y: visible;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.modal-close-btn {
|
| 48 |
+
position: absolute;
|
| 49 |
+
top: 16px;
|
| 50 |
+
right: 18px;
|
| 51 |
+
background: none;
|
| 52 |
+
border: none;
|
| 53 |
+
color: #a89f97;
|
| 54 |
+
font-size: 18px;
|
| 55 |
+
cursor: pointer;
|
| 56 |
+
transition: color 0.2s;
|
| 57 |
+
}
|
| 58 |
+
.modal-close-btn:hover {
|
| 59 |
+
color: #f0ece6;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.modal-title {
|
| 63 |
+
color: #f0ece6;
|
| 64 |
+
font-size: 1.1rem;
|
| 65 |
+
font-weight: 700;
|
| 66 |
+
margin-bottom: 8px;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.modal-subtitle {
|
| 70 |
+
color: #a89f97;
|
| 71 |
+
font-size: 11px;
|
| 72 |
+
margin-bottom: 20px;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* ---- Modal List (legal content) ---- */
|
| 76 |
+
.modal-list {
|
| 77 |
+
color: #a89f97;
|
| 78 |
+
font-size: 11px;
|
| 79 |
+
line-height: 1.9;
|
| 80 |
+
padding-left: 16px;
|
| 81 |
+
list-style: disc;
|
| 82 |
+
text-align: left;
|
| 83 |
+
}
|
| 84 |
+
.modal-list strong {
|
| 85 |
+
color: #f0ece6;
|
| 86 |
+
}
|
| 87 |
+
.modal-list .hl-accent {
|
| 88 |
+
color: #c89a6c;
|
| 89 |
+
font-weight: 700;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* ---- Legal Section Label ---- */
|
| 93 |
+
.legal-section-label {
|
| 94 |
+
color: #c89a6c;
|
| 95 |
+
font-size: 11px;
|
| 96 |
+
font-weight: 700;
|
| 97 |
+
margin-bottom: 6px;
|
| 98 |
+
text-align: left;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* ---- Legal Footer ---- */
|
| 102 |
+
.legal-footer-text {
|
| 103 |
+
color: #555;
|
| 104 |
+
font-size: 10px;
|
| 105 |
+
margin-top: 20px;
|
| 106 |
+
text-align: left;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* ---- Legal Button (footer/sidebar links) ---- */
|
| 110 |
+
.legal-btn {
|
| 111 |
+
font-size: 10px;
|
| 112 |
+
font-weight: 700;
|
| 113 |
+
text-transform: uppercase;
|
| 114 |
+
letter-spacing: 0.2em;
|
| 115 |
+
color: #a89f97;
|
| 116 |
+
background: none;
|
| 117 |
+
border: none;
|
| 118 |
+
cursor: pointer;
|
| 119 |
+
transition: color 0.2s;
|
| 120 |
+
}
|
| 121 |
+
.legal-btn:hover {
|
| 122 |
+
color: #c89a6c;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* ---- Section Label ---- */
|
| 126 |
+
.section-label {
|
| 127 |
+
font-size: 10px;
|
| 128 |
+
font-weight: 800;
|
| 129 |
+
text-transform: uppercase;
|
| 130 |
+
letter-spacing: 0.2em;
|
| 131 |
+
color: #a89f97;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* ---- Check List (hero feature bullets) ---- */
|
| 135 |
+
.check-list {
|
| 136 |
+
list-style: none;
|
| 137 |
+
padding: 0;
|
| 138 |
+
margin: 0;
|
| 139 |
+
}
|
| 140 |
+
.check-list li {
|
| 141 |
+
display: flex;
|
| 142 |
+
align-items: center;
|
| 143 |
+
color: #a89f97;
|
| 144 |
+
}
|
| 145 |
+
.check-list li::before {
|
| 146 |
+
content: '\f00c'; /* fa-check */
|
| 147 |
+
font-family: 'Font Awesome 6 Free';
|
| 148 |
+
font-weight: 900;
|
| 149 |
+
color: #c89a6c;
|
| 150 |
+
font-size: 1.1rem;
|
| 151 |
+
margin-right: 12px;
|
| 152 |
+
flex-shrink: 0;
|
| 153 |
+
width: 1.5rem;
|
| 154 |
+
text-align: center;
|
| 155 |
+
}
|
| 156 |
+
@media (min-width: 768px) {
|
| 157 |
+
.check-list li::before {
|
| 158 |
+
margin-right: 20px;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* ---- Bullet List (about tab) ---- */
|
| 163 |
+
.bullet-list {
|
| 164 |
+
list-style: none;
|
| 165 |
+
padding: 0;
|
| 166 |
+
margin: 0;
|
| 167 |
+
}
|
| 168 |
+
.bullet-list li {
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: flex-start;
|
| 171 |
+
gap: 12px;
|
| 172 |
+
}
|
| 173 |
+
.bullet-list li::before {
|
| 174 |
+
content: '\f111'; /* fa-circle */
|
| 175 |
+
font-family: 'Font Awesome 6 Free';
|
| 176 |
+
font-weight: 900;
|
| 177 |
+
color: #c89a6c;
|
| 178 |
+
font-size: 5px;
|
| 179 |
+
margin-top: 7px;
|
| 180 |
+
flex-shrink: 0;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* ---- Keyboard Shortcut Row ---- */
|
| 184 |
+
.shortcut-row {
|
| 185 |
+
display: flex;
|
| 186 |
+
justify-content: space-between;
|
| 187 |
+
align-items: center;
|
| 188 |
+
padding: 6px 0;
|
| 189 |
+
border-bottom: 1px solid #1a1a1a;
|
| 190 |
+
}
|
| 191 |
+
.shortcut-row:last-child {
|
| 192 |
+
border-bottom: none;
|
| 193 |
+
}
|
| 194 |
+
.shortcut-label {
|
| 195 |
+
color: #a89f97;
|
| 196 |
+
font-size: 11px;
|
| 197 |
+
font-weight: 500;
|
| 198 |
+
}
|
| 199 |
+
.kbd-key {
|
| 200 |
+
background: #1a1a1a;
|
| 201 |
+
color: #c89a6c;
|
| 202 |
+
font-size: 11px;
|
| 203 |
+
font-weight: 700;
|
| 204 |
+
padding: 3px 10px;
|
| 205 |
+
border-radius: 6px;
|
| 206 |
+
border: 1px solid #2a2a2a;
|
| 207 |
+
font-family: 'JetBrains Mono', monospace;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/* ---- Panel Card (run tab panels) ---- */
|
| 211 |
+
.panel-card {
|
| 212 |
+
background-color: #0a0a0a;
|
| 213 |
+
border-radius: 12px;
|
| 214 |
+
border: 1px solid #2a2a2a;
|
| 215 |
+
overflow: hidden;
|
| 216 |
+
display: flex;
|
| 217 |
+
flex-direction: column;
|
| 218 |
+
}
|
| 219 |
+
.panel-header {
|
| 220 |
+
padding: 16px 24px;
|
| 221 |
+
border-bottom: 1px solid #1a1a1a;
|
| 222 |
+
background: #050505;
|
| 223 |
+
}
|
| 224 |
+
.panel-header h3 {
|
| 225 |
+
font-weight: 700;
|
| 226 |
+
font-size: 0.875rem;
|
| 227 |
+
color: #f0ece6;
|
| 228 |
+
}
|
| 229 |
+
.panel-body {
|
| 230 |
+
padding: 24px;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* ---- Step Titles (initial.html steps) ---- */
|
| 234 |
+
.step-title {
|
| 235 |
+
font-size: 1.875rem;
|
| 236 |
+
font-weight: 700;
|
| 237 |
+
margin-bottom: 0.5rem;
|
| 238 |
+
text-align: center;
|
| 239 |
+
color: #f0ece6;
|
| 240 |
+
}
|
| 241 |
+
.step-subtitle {
|
| 242 |
+
font-size: 13px;
|
| 243 |
+
font-weight: 500;
|
| 244 |
+
margin-bottom: 2rem;
|
| 245 |
+
text-align: center;
|
| 246 |
+
color: #a89f97;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/* ---- Mobile Menu Item ---- */
|
| 250 |
+
.mob-menu-item {
|
| 251 |
+
width: 100%;
|
| 252 |
+
text-align: left;
|
| 253 |
+
padding: 10px 16px;
|
| 254 |
+
font-size: 10px;
|
| 255 |
+
font-weight: 700;
|
| 256 |
+
text-transform: uppercase;
|
| 257 |
+
letter-spacing: 0.2em;
|
| 258 |
+
color: #a89f97;
|
| 259 |
+
background: none;
|
| 260 |
+
border: none;
|
| 261 |
+
border-bottom: 1px solid #1a1a1a;
|
| 262 |
+
cursor: pointer;
|
| 263 |
+
transition: color 0.15s, background 0.15s;
|
| 264 |
+
}
|
| 265 |
+
.mob-menu-item:hover {
|
| 266 |
+
color: #f0ece6;
|
| 267 |
+
background: #111;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* ---- Copyright Text ---- */
|
| 271 |
+
.copyright-text {
|
| 272 |
+
font-size: 11px;
|
| 273 |
+
font-weight: 500;
|
| 274 |
+
color: #555;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* ---- Utility ---- */
|
| 278 |
+
.hidden { display: none !important; }
|
| 279 |
+
.border-subtle { border-color: #2a2a2a; }
|
| 280 |
+
.border-dim { border-color: #1a1a1a; }
|
| 281 |
+
.bg-surface { background-color: #050505; }
|
frontend/css/vehicles.css
CHANGED
|
@@ -1,1410 +1,1525 @@
|
|
| 1 |
-
/* =============================================
|
| 2 |
-
UrbanFlow — vehicles.css (Mobile-First)
|
| 3 |
-
Desktop layout preserved exactly.
|
| 4 |
-
Mobile: bottom nav, touch targets, stacked cards.
|
| 5 |
-
============================================= */
|
| 6 |
-
|
| 7 |
-
:root {
|
| 8 |
-
--cocoa: #8b5e3c;
|
| 9 |
-
--cocoa-l: #c89a6c;
|
| 10 |
-
--cocoa-xl: #d4b08a;
|
| 11 |
-
--mob-nav-h: 68px;
|
| 12 |
-
/* bottom nav height on mobile */
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
*,
|
| 16 |
-
*::before,
|
| 17 |
-
*::after {
|
| 18 |
-
box-sizing: border-box;
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
.hidden {
|
| 22 |
-
display: none !important;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
html {
|
| 26 |
-
overflow: hidden;
|
| 27 |
-
height: 100%;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
body {
|
| 31 |
-
font-family: 'Montserrat', sans-serif;
|
| 32 |
-
background-color: #000000;
|
| 33 |
-
color: #f0ece6;
|
| 34 |
-
-webkit-tap-highlight-color: transparent;
|
| 35 |
-
overscroll-behavior: none;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
.mono-font {
|
| 39 |
-
font-family: 'JetBrains Mono', monospace;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
/* ---- Scrollbar: hide globally on mobile, keep #class-breakdown visible ---- */
|
| 43 |
-
@media (max-width: 1023px) {
|
| 44 |
-
* {
|
| 45 |
-
scrollbar-width: none;
|
| 46 |
-
-ms-overflow-style: none;
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
*::-webkit-scrollbar {
|
| 50 |
-
display: none;
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
/* Vehicle Classification section keeps its scrollbar on mobile */
|
| 54 |
-
#class-breakdown {
|
| 55 |
-
scrollbar-width: thin !important;
|
| 56 |
-
-ms-overflow-style: auto !important;
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
#class-breakdown::-webkit-scrollbar {
|
| 60 |
-
display: block !important;
|
| 61 |
-
width: 4px !important;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
#class-breakdown::-webkit-scrollbar-track {
|
| 65 |
-
background: #000000 !important;
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
#class-breakdown::-webkit-scrollbar-thumb {
|
| 69 |
-
background: #222222 !important;
|
| 70 |
-
border-radius: 4px !important;
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
#class-breakdown::-webkit-scrollbar-thumb:hover {
|
| 74 |
-
background: #333333 !important;
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
/* ---- Notification Glow ---- */
|
| 79 |
-
@keyframes glow-green {
|
| 80 |
-
0% {
|
| 81 |
-
color: #f0ece6;
|
| 82 |
-
filter: drop-shadow(0 0 0px #4ade80);
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
50% {
|
| 86 |
-
color: #4ade80;
|
| 87 |
-
filter: drop-shadow(0 0 8px #4ade80);
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
100% {
|
| 91 |
-
color: #f0ece6;
|
| 92 |
-
filter: drop-shadow(0 0 0px #4ade80);
|
| 93 |
-
}
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
.notify-glow i {
|
| 97 |
-
animation: glow-green 1.5s infinite ease-in-out !important;
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
/* ---- Info tooltip ---- */
|
| 101 |
-
.info-wrap {
|
| 102 |
-
position: relative;
|
| 103 |
-
display: inline-flex;
|
| 104 |
-
align-items: center;
|
| 105 |
-
margin-left: 6px;
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
.info-btn {
|
| 109 |
-
display: inline-flex;
|
| 110 |
-
align-items: center;
|
| 111 |
-
justify-content: center;
|
| 112 |
-
width: 18px;
|
| 113 |
-
/* slightly larger for touch */
|
| 114 |
-
height: 18px;
|
| 115 |
-
border-radius: 50%;
|
| 116 |
-
background: #444444 !important;
|
| 117 |
-
color: #ffffff !important;
|
| 118 |
-
font-size: 8px;
|
| 119 |
-
cursor: pointer;
|
| 120 |
-
transition: all 0.2s ease;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
.info-btn:hover,
|
| 124 |
-
.info-btn:active {
|
| 125 |
-
background: #666666 !important;
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
.info-tip {
|
| 129 |
-
display: none;
|
| 130 |
-
position: fixed;
|
| 131 |
-
z-index: 9999;
|
| 132 |
-
background: #0a0a0a;
|
| 133 |
-
color: #aaaaaa;
|
| 134 |
-
font-size: 10px;
|
| 135 |
-
font-weight: 500;
|
| 136 |
-
line-height: 1.4;
|
| 137 |
-
padding: 8px 12px;
|
| 138 |
-
border-radius: 6px;
|
| 139 |
-
max-width: 240px;
|
| 140 |
-
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
|
| 141 |
-
border: 1px solid #222222;
|
| 142 |
-
pointer-events: none;
|
| 143 |
-
text-transform: none;
|
| 144 |
-
letter-spacing: normal;
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
/* ---- Mobile Top Bar ---- */
|
| 148 |
-
.mobile-top-bar {
|
| 149 |
-
display: none;
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
@media (max-width: 1023px) {
|
| 153 |
-
.mobile-top-bar {
|
| 154 |
-
display: flex;
|
| 155 |
-
align-items: center;
|
| 156 |
-
justify-content: center;
|
| 157 |
-
position: fixed;
|
| 158 |
-
top: 0;
|
| 159 |
-
left: 0;
|
| 160 |
-
right: 0;
|
| 161 |
-
height: 58px;
|
| 162 |
-
background: #000000;
|
| 163 |
-
border-bottom: 1px solid #1a1a1a;
|
| 164 |
-
z-index: 35;
|
| 165 |
-
flex-shrink: 0;
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
#legal-menu {
|
| 169 |
-
animation: menuFadeIn 0.2s ease-out forwards;
|
| 170 |
-
transform-origin: top right;
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
-
@keyframes menuFadeIn {
|
| 174 |
-
from {
|
| 175 |
-
opacity: 0;
|
| 176 |
-
transform: translateY(-10px) scale(0.95);
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
to {
|
| 180 |
-
opacity: 1;
|
| 181 |
-
transform: translateY(0) scale(1);
|
| 182 |
-
}
|
| 183 |
-
}
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
/* ---- Sidebar nav states ---- */
|
| 187 |
-
.nav-item-active {
|
| 188 |
-
background-color: #111111 !important;
|
| 189 |
-
color: var(--cocoa-xl) !important;
|
| 190 |
-
border-left: 2px solid var(--cocoa-l) !important;
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
.nav-item-inactive {
|
| 194 |
-
color: #555555 !important;
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
.nav-item-inactive:hover {
|
| 198 |
-
color: #f0ece6 !important;
|
| 199 |
-
background-color: #050505 !important;
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
/* ---- Card overrides ---- */
|
| 203 |
-
.bg-white {
|
| 204 |
-
background-color: #0a0a0a !important;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
.border-slate-200,
|
| 208 |
-
.border-slate-100,
|
| 209 |
-
.border-slate-50,
|
| 210 |
-
.border-neutral-800,
|
| 211 |
-
.border-neutral-900 {
|
| 212 |
-
border-color: #2a2a2a !important;
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
.bg-slate-50\/50,
|
| 216 |
-
.bg-slate-50,
|
| 217 |
-
.bg-slate-900,
|
| 218 |
-
.bg-neutral-900 {
|
| 219 |
-
background-color: #0c0c0c !important;
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
.text-slate-900,
|
| 223 |
-
.text-slate-800,
|
| 224 |
-
.text-slate-700,
|
| 225 |
-
.text-neutral-900 {
|
| 226 |
-
color: #ffffff !important;
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
.text-slate-600,
|
| 230 |
-
.text-slate-500,
|
| 231 |
-
.text-slate-400,
|
| 232 |
-
.text-neutral-500,
|
| 233 |
-
.text-neutral-400 {
|
| 234 |
-
color: #888888 !important;
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
.shadow-sm {
|
| 238 |
-
box-shadow: none !important;
|
| 239 |
-
}
|
| 240 |
-
|
| 241 |
-
/* ---- Toggle control ---- */
|
| 242 |
-
.toggle-track {
|
| 243 |
-
width: 36px;
|
| 244 |
-
/* slightly wider for touch */
|
| 245 |
-
height: 20px;
|
| 246 |
-
border-radius: 999px;
|
| 247 |
-
background: #1a1a1a;
|
| 248 |
-
border: 1px solid #333;
|
| 249 |
-
position: relative;
|
| 250 |
-
cursor: pointer;
|
| 251 |
-
flex-shrink: 0;
|
| 252 |
-
transition: background 0.2s ease;
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
.toggle-track.active {
|
| 256 |
-
background: #c89a6c !important;
|
| 257 |
-
border-color: #c89a6c !important;
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
.toggle-thumb {
|
| 261 |
-
width: 16px;
|
| 262 |
-
height: 16px;
|
| 263 |
-
border-radius: 50%;
|
| 264 |
-
background: #555555;
|
| 265 |
-
position: absolute;
|
| 266 |
-
top: 2px;
|
| 267 |
-
left: 2px;
|
| 268 |
-
transition: all 0.2s ease;
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
.toggle-track.active .toggle-thumb {
|
| 272 |
-
transform: translateX(16px);
|
| 273 |
-
background: #ffffff;
|
| 274 |
-
/* pure white for contrast on gold track */
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
/* ---- Custom select ---- */
|
| 278 |
-
.custom-select {
|
| 279 |
-
appearance: none;
|
| 280 |
-
background-color: #111111;
|
| 281 |
-
border: 1px solid #222222;
|
| 282 |
-
border-radius: 6px;
|
| 283 |
-
padding: 4px 24px 4px 10px;
|
| 284 |
-
font-size: 11px;
|
| 285 |
-
font-weight: 600;
|
| 286 |
-
color: #ffffff;
|
| 287 |
-
outline: none;
|
| 288 |
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666666'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
|
| 289 |
-
background-repeat: no-repeat;
|
| 290 |
-
background-position: right 8px center;
|
| 291 |
-
background-size: 12px;
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
/* ---- Stepper ---- */
|
| 295 |
-
.s-stepper {
|
| 296 |
-
display: inline-flex;
|
| 297 |
-
border: 1px solid #222222;
|
| 298 |
-
border-radius: 6px;
|
| 299 |
-
background: #111111;
|
| 300 |
-
overflow: hidden;
|
| 301 |
-
flex-shrink: 0;
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
.s-stepper button {
|
| 305 |
-
padding: 8px 12px;
|
| 306 |
-
/* larger touch target than original 4px 8px */
|
| 307 |
-
color: #666666;
|
| 308 |
-
font-size: 14px;
|
| 309 |
-
min-width: 36px;
|
| 310 |
-
min-height: 36px;
|
| 311 |
-
display: flex;
|
| 312 |
-
align-items: center;
|
| 313 |
-
justify-content: center;
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
.s-stepper button:hover,
|
| 317 |
-
.s-stepper button:active {
|
| 318 |
-
background: #1a1a1a;
|
| 319 |
-
color: #ffffff;
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
.s-stepper .s-val {
|
| 323 |
-
min-width: 44px;
|
| 324 |
-
text-align: center;
|
| 325 |
-
font-family: 'JetBrains Mono', monospace;
|
| 326 |
-
font-size: 12px;
|
| 327 |
-
font-weight: 700;
|
| 328 |
-
color: #ffffff;
|
| 329 |
-
padding: 4px 0;
|
| 330 |
-
border-left: 1px solid #222222;
|
| 331 |
-
border-right: 1px solid #222222;
|
| 332 |
-
display: flex;
|
| 333 |
-
align-items: center;
|
| 334 |
-
justify-content: center;
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
/* ---- Settings row ---- */
|
| 338 |
-
.s-row {
|
| 339 |
-
display: flex;
|
| 340 |
-
align-items: center;
|
| 341 |
-
justify-content: space-between;
|
| 342 |
-
padding: 14px 0;
|
| 343 |
-
/* slightly more vertical padding */
|
| 344 |
-
border-bottom: 1px solid #1a1a1a;
|
| 345 |
-
gap: 12px;
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
.s-row:last-child {
|
| 349 |
-
border-bottom: none;
|
| 350 |
-
}
|
| 351 |
-
|
| 352 |
-
.s-row>div:first-child {
|
| 353 |
-
flex: 1;
|
| 354 |
-
min-width: 0;
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
/* ---- Progress bar ---- */
|
| 358 |
-
#proc-bar {
|
| 359 |
-
background-color: var(--cocoa-l) !important;
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
-
#proc-label {
|
| 363 |
-
color: #ffffff !important;
|
| 364 |
-
}
|
| 365 |
-
|
| 366 |
-
/* ---- Disabled rows ---- */
|
| 367 |
-
.s-row.disabled {
|
| 368 |
-
opacity: 0.65 !important;
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
.s-row.disabled .s-stepper,
|
| 372 |
-
.s-row.disabled .custom-select,
|
| 373 |
-
.s-row.disabled .toggle-track,
|
| 374 |
-
.s-row.disabled .chip-container,
|
| 375 |
-
.s-row.disabled .uf-select-wrap,
|
| 376 |
-
.s-row.disabled .uf-select-trigger {
|
| 377 |
-
pointer-events: none !important;
|
| 378 |
-
opacity: 0.5 !important;
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
/* Force-collapse the dropdown panel when row is locked */
|
| 382 |
-
.s-row.disabled .uf-select-dropdown {
|
| 383 |
-
display: none !important;
|
| 384 |
-
}
|
| 385 |
-
|
| 386 |
-
.s-row.disabled .info-wrap {
|
| 387 |
-
pointer-events: auto !important;
|
| 388 |
-
opacity: 1 !important;
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
-
#btn-start-processing {
|
| 392 |
-
font-family: 'Montserrat', sans-serif !important;
|
| 393 |
-
}
|
| 394 |
-
|
| 395 |
-
/* ---- Chips ---- */
|
| 396 |
-
.chip-container {
|
| 397 |
-
display: flex;
|
| 398 |
-
flex-wrap: wrap;
|
| 399 |
-
gap: 8px;
|
| 400 |
-
margin-top: 12px;
|
| 401 |
-
padding-top: 12px;
|
| 402 |
-
border-top: 1px solid #1a1a1a;
|
| 403 |
-
transition: all 0.3s ease;
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
-
.chip {
|
| 407 |
-
display: inline-flex;
|
| 408 |
-
align-items: center;
|
| 409 |
-
gap: 6px;
|
| 410 |
-
padding: 8px 14px;
|
| 411 |
-
/* larger than original 6px 14px */
|
| 412 |
-
border-radius: 9999px;
|
| 413 |
-
font-size: 10px;
|
| 414 |
-
font-weight: 700;
|
| 415 |
-
cursor: pointer;
|
| 416 |
-
transition: all 0.2s ease;
|
| 417 |
-
user-select: none;
|
| 418 |
-
border: 1px solid #333333;
|
| 419 |
-
background: rgba(255, 255, 255, 0.03);
|
| 420 |
-
color: #888888;
|
| 421 |
-
min-height: 36px;
|
| 422 |
-
}
|
| 423 |
-
|
| 424 |
-
.chip.active {
|
| 425 |
-
background: var(--cocoa-l);
|
| 426 |
-
color: #000000;
|
| 427 |
-
border-color: var(--cocoa-l);
|
| 428 |
-
}
|
| 429 |
-
|
| 430 |
-
.chip.frozen {
|
| 431 |
-
background: rgba(255, 255, 255, 0.4);
|
| 432 |
-
color: #000000;
|
| 433 |
-
border-color: transparent;
|
| 434 |
-
cursor: default !important;
|
| 435 |
-
pointer-events: none;
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
.chip:hover {
|
| 439 |
-
border-color: #666666;
|
| 440 |
-
}
|
| 441 |
-
|
| 442 |
-
.chip.active:hover {
|
| 443 |
-
background: var(--cocoa-xl);
|
| 444 |
-
}
|
| 445 |
-
|
| 446 |
-
.chip i {
|
| 447 |
-
font-size: 9px;
|
| 448 |
-
}
|
| 449 |
-
|
| 450 |
-
.hidden-chip-container {
|
| 451 |
-
display: none !important;
|
| 452 |
-
margin: 0 !important;
|
| 453 |
-
padding: 0 !important;
|
| 454 |
-
height: 0 !important;
|
| 455 |
-
}
|
| 456 |
-
|
| 457 |
-
/* ---- Toast ---- */
|
| 458 |
-
#toast-container {
|
| 459 |
-
position: fixed;
|
| 460 |
-
bottom: calc(var(--mob-nav-h) + 12px);
|
| 461 |
-
/* above bottom nav on mobile */
|
| 462 |
-
left: 50%;
|
| 463 |
-
transform: translateX(-50%);
|
| 464 |
-
z-index: 10000;
|
| 465 |
-
display: flex;
|
| 466 |
-
flex-direction: column;
|
| 467 |
-
align-items: center;
|
| 468 |
-
gap: 8px;
|
| 469 |
-
pointer-events: none;
|
| 470 |
-
width: 90%;
|
| 471 |
-
max-width: 360px;
|
| 472 |
-
}
|
| 473 |
-
|
| 474 |
-
.toast {
|
| 475 |
-
background: #111;
|
| 476 |
-
border: 1px solid #2a2a2a;
|
| 477 |
-
color: #f0ece6;
|
| 478 |
-
font-size: 11px;
|
| 479 |
-
font-weight: 600;
|
| 480 |
-
padding: 12px 18px;
|
| 481 |
-
border-radius: 10px;
|
| 482 |
-
display: flex;
|
| 483 |
-
align-items: center;
|
| 484 |
-
gap: 8px;
|
| 485 |
-
pointer-events: auto;
|
| 486 |
-
animation: toastIn 0.3s ease-out;
|
| 487 |
-
width: 100%;
|
| 488 |
-
}
|
| 489 |
-
|
| 490 |
-
.toast.toast-out {
|
| 491 |
-
animation: toastOut 0.3s ease-in forwards;
|
| 492 |
-
}
|
| 493 |
-
|
| 494 |
-
.toast-success {
|
| 495 |
-
border-color: #166534;
|
| 496 |
-
}
|
| 497 |
-
|
| 498 |
-
.toast-success i {
|
| 499 |
-
color: #22c55e;
|
| 500 |
-
}
|
| 501 |
-
|
| 502 |
-
.toast-error {
|
| 503 |
-
border-color: #7f1d1d;
|
| 504 |
-
}
|
| 505 |
-
|
| 506 |
-
.toast-error i {
|
| 507 |
-
color: #ef4444;
|
| 508 |
-
}
|
| 509 |
-
|
| 510 |
-
.toast-info i {
|
| 511 |
-
color: var(--cocoa-l);
|
| 512 |
-
}
|
| 513 |
-
|
| 514 |
-
@keyframes toastIn {
|
| 515 |
-
from {
|
| 516 |
-
opacity: 0;
|
| 517 |
-
transform: translateY(20px);
|
| 518 |
-
}
|
| 519 |
-
|
| 520 |
-
to {
|
| 521 |
-
opacity: 1;
|
| 522 |
-
transform: translateY(0);
|
| 523 |
-
}
|
| 524 |
-
}
|
| 525 |
-
|
| 526 |
-
@keyframes toastOut {
|
| 527 |
-
from {
|
| 528 |
-
opacity: 1;
|
| 529 |
-
}
|
| 530 |
-
|
| 531 |
-
to {
|
| 532 |
-
opacity: 0;
|
| 533 |
-
transform: translateY(20px);
|
| 534 |
-
}
|
| 535 |
-
}
|
| 536 |
-
|
| 537 |
-
/* ---- Stats empty overlay ---- */
|
| 538 |
-
.stats-empty-overlay {
|
| 539 |
-
position: absolute;
|
| 540 |
-
inset: 0;
|
| 541 |
-
z-index: 50;
|
| 542 |
-
display: flex;
|
| 543 |
-
flex-direction: column;
|
| 544 |
-
align-items: center;
|
| 545 |
-
justify-content: center;
|
| 546 |
-
background: rgba(10, 10, 10, 0.85);
|
| 547 |
-
backdrop-filter: blur(8px);
|
| 548 |
-
border-radius: 12px;
|
| 549 |
-
}
|
| 550 |
-
|
| 551 |
-
/* ---- Feedback form ---- */
|
| 552 |
-
.fb-textarea {
|
| 553 |
-
background: #111;
|
| 554 |
-
border: 1px solid #2a2a2a;
|
| 555 |
-
border-radius: 8px;
|
| 556 |
-
color: #f0ece6;
|
| 557 |
-
font-size: 12px;
|
| 558 |
-
padding: 12px;
|
| 559 |
-
width: 100%;
|
| 560 |
-
min-height: 120px;
|
| 561 |
-
resize: vertical;
|
| 562 |
-
font-family: 'Inter', sans-serif;
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
.fb-textarea:focus {
|
| 566 |
-
outline: none;
|
| 567 |
-
border-color: var(--cocoa-l);
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
.fb-select {
|
| 571 |
-
background: #111;
|
| 572 |
-
border: 1px solid #2a2a2a;
|
| 573 |
-
border-radius: 8px;
|
| 574 |
-
color: #f0ece6;
|
| 575 |
-
font-size: 11px;
|
| 576 |
-
padding: 10px 12px;
|
| 577 |
-
/* taller for touch */
|
| 578 |
-
width: 100%;
|
| 579 |
-
font-family: 'Inter', sans-serif;
|
| 580 |
-
min-height: 44px;
|
| 581 |
-
}
|
| 582 |
-
|
| 583 |
-
.fb-select:focus {
|
| 584 |
-
outline: none;
|
| 585 |
-
border-color: var(--cocoa-l);
|
| 586 |
-
}
|
| 587 |
-
|
| 588 |
-
.fb-stars {
|
| 589 |
-
display: flex;
|
| 590 |
-
gap: 8px;
|
| 591 |
-
}
|
| 592 |
-
|
| 593 |
-
.fb-star {
|
| 594 |
-
font-size: 28px;
|
| 595 |
-
/* larger for mobile tapping */
|
| 596 |
-
color: #333;
|
| 597 |
-
cursor: pointer;
|
| 598 |
-
transition: color 0.15s;
|
| 599 |
-
min-width: 36px;
|
| 600 |
-
min-height: 36px;
|
| 601 |
-
display: flex;
|
| 602 |
-
align-items: center;
|
| 603 |
-
justify-content: center;
|
| 604 |
-
}
|
| 605 |
-
|
| 606 |
-
.fb-star.active,
|
| 607 |
-
.fb-star:hover {
|
| 608 |
-
color: var(--cocoa-l);
|
| 609 |
-
}
|
| 610 |
-
|
| 611 |
-
.fb-chip {
|
| 612 |
-
background: #050505;
|
| 613 |
-
border: 1px solid #222;
|
| 614 |
-
border-radius: 8px;
|
| 615 |
-
color: #666;
|
| 616 |
-
font-size: 10px;
|
| 617 |
-
font-weight: 700;
|
| 618 |
-
padding: 14px 12px;
|
| 619 |
-
/* taller for touch */
|
| 620 |
-
cursor: pointer;
|
| 621 |
-
transition: all 0.2s ease;
|
| 622 |
-
text-align: center;
|
| 623 |
-
text-transform: uppercase;
|
| 624 |
-
min-height: 44px;
|
| 625 |
-
display: flex;
|
| 626 |
-
align-items: center;
|
| 627 |
-
justify-content: center;
|
| 628 |
-
}
|
| 629 |
-
|
| 630 |
-
.fb-chip:hover {
|
| 631 |
-
border-color: #444;
|
| 632 |
-
color: #999;
|
| 633 |
-
}
|
| 634 |
-
|
| 635 |
-
.fb-chip.active {
|
| 636 |
-
border-color: var(--cocoa-l);
|
| 637 |
-
background: #111;
|
| 638 |
-
color: #fff;
|
| 639 |
-
box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
|
| 640 |
-
}
|
| 641 |
-
|
| 642 |
-
.fb-emoji-btn {
|
| 643 |
-
background: #111;
|
| 644 |
-
border: 1px solid #2a2a2a;
|
| 645 |
-
border-radius: 8px;
|
| 646 |
-
color: #555;
|
| 647 |
-
flex: 1;
|
| 648 |
-
text-align: center;
|
| 649 |
-
padding: 12px 4px;
|
| 650 |
-
/* taller */
|
| 651 |
-
cursor: pointer;
|
| 652 |
-
transition: all 0.2s ease;
|
| 653 |
-
min-height: 64px;
|
| 654 |
-
display: flex;
|
| 655 |
-
flex-direction: column;
|
| 656 |
-
align-items: center;
|
| 657 |
-
justify-content: center;
|
| 658 |
-
}
|
| 659 |
-
|
| 660 |
-
.fb-emoji-btn:hover {
|
| 661 |
-
border-color: #444;
|
| 662 |
-
color: #888;
|
| 663 |
-
}
|
| 664 |
-
|
| 665 |
-
.fb-emoji-btn.active {
|
| 666 |
-
border-color: var(--cocoa-l);
|
| 667 |
-
background: #1a1a1a;
|
| 668 |
-
color: var(--cocoa-l);
|
| 669 |
-
box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
|
| 670 |
-
}
|
| 671 |
-
|
| 672 |
-
/* =============================================
|
| 673 |
-
DESKTOP (≥1024px) — original layout intact
|
| 674 |
-
============================================= */
|
| 675 |
-
@media (min-width: 1024px) {
|
| 676 |
-
|
| 677 |
-
/* Sidebar visible */
|
| 678 |
-
aside.w-60 {
|
| 679 |
-
display: flex !important;
|
| 680 |
-
}
|
| 681 |
-
|
| 682 |
-
/* Top mobile nav hidden */
|
| 683 |
-
.mobile-nav {
|
| 684 |
-
display: none !important;
|
| 685 |
-
}
|
| 686 |
-
|
| 687 |
-
/* Bottom mobile nav hidden */
|
| 688 |
-
.mobile-bottom-nav {
|
| 689 |
-
display: none !important;
|
| 690 |
-
}
|
| 691 |
-
|
| 692 |
-
/* Main — no bottom padding needed */
|
| 693 |
-
main {
|
| 694 |
-
padding-bottom: 1rem !important;
|
| 695 |
-
}
|
| 696 |
-
|
| 697 |
-
/* Toast — desktop position: bottom-right */
|
| 698 |
-
#toast-container {
|
| 699 |
-
bottom: 20px;
|
| 700 |
-
left: unset;
|
| 701 |
-
right: 20px;
|
| 702 |
-
transform: none;
|
| 703 |
-
width: auto;
|
| 704 |
-
align-items: flex-end;
|
| 705 |
-
}
|
| 706 |
-
|
| 707 |
-
/* Settings — 2 column grid */
|
| 708 |
-
#tab-settings .grid {
|
| 709 |
-
grid-template-columns: repeat(2, 1fr) !important;
|
| 710 |
-
}
|
| 711 |
-
|
| 712 |
-
/* Run details — multi-column grids preserved */
|
| 713 |
-
#run-results-content {
|
| 714 |
-
grid-template-columns: repeat(3, 1fr) !important;
|
| 715 |
-
}
|
| 716 |
-
|
| 717 |
-
.grid-cols-2 {
|
| 718 |
-
grid-template-columns: repeat(2, 1fr) !important;
|
| 719 |
-
}
|
| 720 |
-
|
| 721 |
-
.grid-cols-3 {
|
| 722 |
-
grid-template-columns: repeat(3, 1fr) !important;
|
| 723 |
-
}
|
| 724 |
-
|
| 725 |
-
/* Reports grid */
|
| 726 |
-
#reports-grid,
|
| 727 |
-
#reports-pending {
|
| 728 |
-
grid-template-columns: repeat(2, 1fr) !important;
|
| 729 |
-
}
|
| 730 |
-
|
| 731 |
-
/* About grid */
|
| 732 |
-
#tab-about .grid.grid-cols-3 {
|
| 733 |
-
grid-template-columns: repeat(3, 1fr) !important;
|
| 734 |
-
}
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
#tab-
|
| 780 |
-
#tab-
|
| 781 |
-
#tab-
|
| 782 |
-
#tab-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
#panel-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
.grid-cols-3
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
|
| 1319 |
-
color:
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
.
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
color:
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================
|
| 2 |
+
UrbanFlow — vehicles.css (Mobile-First)
|
| 3 |
+
Desktop layout preserved exactly.
|
| 4 |
+
Mobile: bottom nav, touch targets, stacked cards.
|
| 5 |
+
============================================= */
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
--cocoa: #8b5e3c;
|
| 9 |
+
--cocoa-l: #c89a6c;
|
| 10 |
+
--cocoa-xl: #d4b08a;
|
| 11 |
+
--mob-nav-h: 68px;
|
| 12 |
+
/* bottom nav height on mobile */
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
*,
|
| 16 |
+
*::before,
|
| 17 |
+
*::after {
|
| 18 |
+
box-sizing: border-box;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.hidden {
|
| 22 |
+
display: none !important;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
html {
|
| 26 |
+
overflow: hidden;
|
| 27 |
+
height: 100%;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
body {
|
| 31 |
+
font-family: 'Montserrat', sans-serif;
|
| 32 |
+
background-color: #000000;
|
| 33 |
+
color: #f0ece6;
|
| 34 |
+
-webkit-tap-highlight-color: transparent;
|
| 35 |
+
overscroll-behavior: none;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.mono-font {
|
| 39 |
+
font-family: 'JetBrains Mono', monospace;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* ---- Scrollbar: hide globally on mobile, keep #class-breakdown visible ---- */
|
| 43 |
+
@media (max-width: 1023px) {
|
| 44 |
+
* {
|
| 45 |
+
scrollbar-width: none;
|
| 46 |
+
-ms-overflow-style: none;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
*::-webkit-scrollbar {
|
| 50 |
+
display: none;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* Vehicle Classification section keeps its scrollbar on mobile */
|
| 54 |
+
#class-breakdown {
|
| 55 |
+
scrollbar-width: thin !important;
|
| 56 |
+
-ms-overflow-style: auto !important;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#class-breakdown::-webkit-scrollbar {
|
| 60 |
+
display: block !important;
|
| 61 |
+
width: 4px !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#class-breakdown::-webkit-scrollbar-track {
|
| 65 |
+
background: #000000 !important;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#class-breakdown::-webkit-scrollbar-thumb {
|
| 69 |
+
background: #222222 !important;
|
| 70 |
+
border-radius: 4px !important;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#class-breakdown::-webkit-scrollbar-thumb:hover {
|
| 74 |
+
background: #333333 !important;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* ---- Notification Glow ---- */
|
| 79 |
+
@keyframes glow-green {
|
| 80 |
+
0% {
|
| 81 |
+
color: #f0ece6;
|
| 82 |
+
filter: drop-shadow(0 0 0px #4ade80);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
50% {
|
| 86 |
+
color: #4ade80;
|
| 87 |
+
filter: drop-shadow(0 0 8px #4ade80);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
100% {
|
| 91 |
+
color: #f0ece6;
|
| 92 |
+
filter: drop-shadow(0 0 0px #4ade80);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.notify-glow i {
|
| 97 |
+
animation: glow-green 1.5s infinite ease-in-out !important;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* ---- Info tooltip ---- */
|
| 101 |
+
.info-wrap {
|
| 102 |
+
position: relative;
|
| 103 |
+
display: inline-flex;
|
| 104 |
+
align-items: center;
|
| 105 |
+
margin-left: 6px;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.info-btn {
|
| 109 |
+
display: inline-flex;
|
| 110 |
+
align-items: center;
|
| 111 |
+
justify-content: center;
|
| 112 |
+
width: 18px;
|
| 113 |
+
/* slightly larger for touch */
|
| 114 |
+
height: 18px;
|
| 115 |
+
border-radius: 50%;
|
| 116 |
+
background: #444444 !important;
|
| 117 |
+
color: #ffffff !important;
|
| 118 |
+
font-size: 8px;
|
| 119 |
+
cursor: pointer;
|
| 120 |
+
transition: all 0.2s ease;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.info-btn:hover,
|
| 124 |
+
.info-btn:active {
|
| 125 |
+
background: #666666 !important;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.info-tip {
|
| 129 |
+
display: none;
|
| 130 |
+
position: fixed;
|
| 131 |
+
z-index: 9999;
|
| 132 |
+
background: #0a0a0a;
|
| 133 |
+
color: #aaaaaa;
|
| 134 |
+
font-size: 10px;
|
| 135 |
+
font-weight: 500;
|
| 136 |
+
line-height: 1.4;
|
| 137 |
+
padding: 8px 12px;
|
| 138 |
+
border-radius: 6px;
|
| 139 |
+
max-width: 240px;
|
| 140 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
|
| 141 |
+
border: 1px solid #222222;
|
| 142 |
+
pointer-events: none;
|
| 143 |
+
text-transform: none;
|
| 144 |
+
letter-spacing: normal;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/* ---- Mobile Top Bar ---- */
|
| 148 |
+
.mobile-top-bar {
|
| 149 |
+
display: none;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
@media (max-width: 1023px) {
|
| 153 |
+
.mobile-top-bar {
|
| 154 |
+
display: flex;
|
| 155 |
+
align-items: center;
|
| 156 |
+
justify-content: center;
|
| 157 |
+
position: fixed;
|
| 158 |
+
top: 0;
|
| 159 |
+
left: 0;
|
| 160 |
+
right: 0;
|
| 161 |
+
height: 58px;
|
| 162 |
+
background: #000000;
|
| 163 |
+
border-bottom: 1px solid #1a1a1a;
|
| 164 |
+
z-index: 35;
|
| 165 |
+
flex-shrink: 0;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
#legal-menu {
|
| 169 |
+
animation: menuFadeIn 0.2s ease-out forwards;
|
| 170 |
+
transform-origin: top right;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
@keyframes menuFadeIn {
|
| 174 |
+
from {
|
| 175 |
+
opacity: 0;
|
| 176 |
+
transform: translateY(-10px) scale(0.95);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
to {
|
| 180 |
+
opacity: 1;
|
| 181 |
+
transform: translateY(0) scale(1);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/* ---- Sidebar nav states ---- */
|
| 187 |
+
.nav-item-active {
|
| 188 |
+
background-color: #111111 !important;
|
| 189 |
+
color: var(--cocoa-xl) !important;
|
| 190 |
+
border-left: 2px solid var(--cocoa-l) !important;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.nav-item-inactive {
|
| 194 |
+
color: #555555 !important;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.nav-item-inactive:hover {
|
| 198 |
+
color: #f0ece6 !important;
|
| 199 |
+
background-color: #050505 !important;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/* ---- Card overrides ---- */
|
| 203 |
+
.bg-white {
|
| 204 |
+
background-color: #0a0a0a !important;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.border-slate-200,
|
| 208 |
+
.border-slate-100,
|
| 209 |
+
.border-slate-50,
|
| 210 |
+
.border-neutral-800,
|
| 211 |
+
.border-neutral-900 {
|
| 212 |
+
border-color: #2a2a2a !important;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.bg-slate-50\/50,
|
| 216 |
+
.bg-slate-50,
|
| 217 |
+
.bg-slate-900,
|
| 218 |
+
.bg-neutral-900 {
|
| 219 |
+
background-color: #0c0c0c !important;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.text-slate-900,
|
| 223 |
+
.text-slate-800,
|
| 224 |
+
.text-slate-700,
|
| 225 |
+
.text-neutral-900 {
|
| 226 |
+
color: #ffffff !important;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.text-slate-600,
|
| 230 |
+
.text-slate-500,
|
| 231 |
+
.text-slate-400,
|
| 232 |
+
.text-neutral-500,
|
| 233 |
+
.text-neutral-400 {
|
| 234 |
+
color: #888888 !important;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.shadow-sm {
|
| 238 |
+
box-shadow: none !important;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* ---- Toggle control ---- */
|
| 242 |
+
.toggle-track {
|
| 243 |
+
width: 36px;
|
| 244 |
+
/* slightly wider for touch */
|
| 245 |
+
height: 20px;
|
| 246 |
+
border-radius: 999px;
|
| 247 |
+
background: #1a1a1a;
|
| 248 |
+
border: 1px solid #333;
|
| 249 |
+
position: relative;
|
| 250 |
+
cursor: pointer;
|
| 251 |
+
flex-shrink: 0;
|
| 252 |
+
transition: background 0.2s ease;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.toggle-track.active {
|
| 256 |
+
background: #c89a6c !important;
|
| 257 |
+
border-color: #c89a6c !important;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.toggle-thumb {
|
| 261 |
+
width: 16px;
|
| 262 |
+
height: 16px;
|
| 263 |
+
border-radius: 50%;
|
| 264 |
+
background: #555555;
|
| 265 |
+
position: absolute;
|
| 266 |
+
top: 2px;
|
| 267 |
+
left: 2px;
|
| 268 |
+
transition: all 0.2s ease;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.toggle-track.active .toggle-thumb {
|
| 272 |
+
transform: translateX(16px);
|
| 273 |
+
background: #ffffff;
|
| 274 |
+
/* pure white for contrast on gold track */
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* ---- Custom select ---- */
|
| 278 |
+
.custom-select {
|
| 279 |
+
appearance: none;
|
| 280 |
+
background-color: #111111;
|
| 281 |
+
border: 1px solid #222222;
|
| 282 |
+
border-radius: 6px;
|
| 283 |
+
padding: 4px 24px 4px 10px;
|
| 284 |
+
font-size: 11px;
|
| 285 |
+
font-weight: 600;
|
| 286 |
+
color: #ffffff;
|
| 287 |
+
outline: none;
|
| 288 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666666'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
|
| 289 |
+
background-repeat: no-repeat;
|
| 290 |
+
background-position: right 8px center;
|
| 291 |
+
background-size: 12px;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* ---- Stepper ---- */
|
| 295 |
+
.s-stepper {
|
| 296 |
+
display: inline-flex;
|
| 297 |
+
border: 1px solid #222222;
|
| 298 |
+
border-radius: 6px;
|
| 299 |
+
background: #111111;
|
| 300 |
+
overflow: hidden;
|
| 301 |
+
flex-shrink: 0;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.s-stepper button {
|
| 305 |
+
padding: 8px 12px;
|
| 306 |
+
/* larger touch target than original 4px 8px */
|
| 307 |
+
color: #666666;
|
| 308 |
+
font-size: 14px;
|
| 309 |
+
min-width: 36px;
|
| 310 |
+
min-height: 36px;
|
| 311 |
+
display: flex;
|
| 312 |
+
align-items: center;
|
| 313 |
+
justify-content: center;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.s-stepper button:hover,
|
| 317 |
+
.s-stepper button:active {
|
| 318 |
+
background: #1a1a1a;
|
| 319 |
+
color: #ffffff;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.s-stepper .s-val {
|
| 323 |
+
min-width: 44px;
|
| 324 |
+
text-align: center;
|
| 325 |
+
font-family: 'JetBrains Mono', monospace;
|
| 326 |
+
font-size: 12px;
|
| 327 |
+
font-weight: 700;
|
| 328 |
+
color: #ffffff;
|
| 329 |
+
padding: 4px 0;
|
| 330 |
+
border-left: 1px solid #222222;
|
| 331 |
+
border-right: 1px solid #222222;
|
| 332 |
+
display: flex;
|
| 333 |
+
align-items: center;
|
| 334 |
+
justify-content: center;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/* ---- Settings row ---- */
|
| 338 |
+
.s-row {
|
| 339 |
+
display: flex;
|
| 340 |
+
align-items: center;
|
| 341 |
+
justify-content: space-between;
|
| 342 |
+
padding: 14px 0;
|
| 343 |
+
/* slightly more vertical padding */
|
| 344 |
+
border-bottom: 1px solid #1a1a1a;
|
| 345 |
+
gap: 12px;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.s-row:last-child {
|
| 349 |
+
border-bottom: none;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.s-row>div:first-child {
|
| 353 |
+
flex: 1;
|
| 354 |
+
min-width: 0;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/* ---- Progress bar ---- */
|
| 358 |
+
#proc-bar {
|
| 359 |
+
background-color: var(--cocoa-l) !important;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
#proc-label {
|
| 363 |
+
color: #ffffff !important;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* ---- Disabled rows ---- */
|
| 367 |
+
.s-row.disabled {
|
| 368 |
+
opacity: 0.65 !important;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.s-row.disabled .s-stepper,
|
| 372 |
+
.s-row.disabled .custom-select,
|
| 373 |
+
.s-row.disabled .toggle-track,
|
| 374 |
+
.s-row.disabled .chip-container,
|
| 375 |
+
.s-row.disabled .uf-select-wrap,
|
| 376 |
+
.s-row.disabled .uf-select-trigger {
|
| 377 |
+
pointer-events: none !important;
|
| 378 |
+
opacity: 0.5 !important;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
/* Force-collapse the dropdown panel when row is locked */
|
| 382 |
+
.s-row.disabled .uf-select-dropdown {
|
| 383 |
+
display: none !important;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.s-row.disabled .info-wrap {
|
| 387 |
+
pointer-events: auto !important;
|
| 388 |
+
opacity: 1 !important;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
#btn-start-processing {
|
| 392 |
+
font-family: 'Montserrat', sans-serif !important;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/* ---- Chips ---- */
|
| 396 |
+
.chip-container {
|
| 397 |
+
display: flex;
|
| 398 |
+
flex-wrap: wrap;
|
| 399 |
+
gap: 8px;
|
| 400 |
+
margin-top: 12px;
|
| 401 |
+
padding-top: 12px;
|
| 402 |
+
border-top: 1px solid #1a1a1a;
|
| 403 |
+
transition: all 0.3s ease;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.chip {
|
| 407 |
+
display: inline-flex;
|
| 408 |
+
align-items: center;
|
| 409 |
+
gap: 6px;
|
| 410 |
+
padding: 8px 14px;
|
| 411 |
+
/* larger than original 6px 14px */
|
| 412 |
+
border-radius: 9999px;
|
| 413 |
+
font-size: 10px;
|
| 414 |
+
font-weight: 700;
|
| 415 |
+
cursor: pointer;
|
| 416 |
+
transition: all 0.2s ease;
|
| 417 |
+
user-select: none;
|
| 418 |
+
border: 1px solid #333333;
|
| 419 |
+
background: rgba(255, 255, 255, 0.03);
|
| 420 |
+
color: #888888;
|
| 421 |
+
min-height: 36px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.chip.active {
|
| 425 |
+
background: var(--cocoa-l);
|
| 426 |
+
color: #000000;
|
| 427 |
+
border-color: var(--cocoa-l);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.chip.frozen {
|
| 431 |
+
background: rgba(255, 255, 255, 0.4);
|
| 432 |
+
color: #000000;
|
| 433 |
+
border-color: transparent;
|
| 434 |
+
cursor: default !important;
|
| 435 |
+
pointer-events: none;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.chip:hover {
|
| 439 |
+
border-color: #666666;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.chip.active:hover {
|
| 443 |
+
background: var(--cocoa-xl);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.chip i {
|
| 447 |
+
font-size: 9px;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.hidden-chip-container {
|
| 451 |
+
display: none !important;
|
| 452 |
+
margin: 0 !important;
|
| 453 |
+
padding: 0 !important;
|
| 454 |
+
height: 0 !important;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
/* ---- Toast ---- */
|
| 458 |
+
#toast-container {
|
| 459 |
+
position: fixed;
|
| 460 |
+
bottom: calc(var(--mob-nav-h) + 12px);
|
| 461 |
+
/* above bottom nav on mobile */
|
| 462 |
+
left: 50%;
|
| 463 |
+
transform: translateX(-50%);
|
| 464 |
+
z-index: 10000;
|
| 465 |
+
display: flex;
|
| 466 |
+
flex-direction: column;
|
| 467 |
+
align-items: center;
|
| 468 |
+
gap: 8px;
|
| 469 |
+
pointer-events: none;
|
| 470 |
+
width: 90%;
|
| 471 |
+
max-width: 360px;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.toast {
|
| 475 |
+
background: #111;
|
| 476 |
+
border: 1px solid #2a2a2a;
|
| 477 |
+
color: #f0ece6;
|
| 478 |
+
font-size: 11px;
|
| 479 |
+
font-weight: 600;
|
| 480 |
+
padding: 12px 18px;
|
| 481 |
+
border-radius: 10px;
|
| 482 |
+
display: flex;
|
| 483 |
+
align-items: center;
|
| 484 |
+
gap: 8px;
|
| 485 |
+
pointer-events: auto;
|
| 486 |
+
animation: toastIn 0.3s ease-out;
|
| 487 |
+
width: 100%;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.toast.toast-out {
|
| 491 |
+
animation: toastOut 0.3s ease-in forwards;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.toast-success {
|
| 495 |
+
border-color: #166534;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.toast-success i {
|
| 499 |
+
color: #22c55e;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.toast-error {
|
| 503 |
+
border-color: #7f1d1d;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.toast-error i {
|
| 507 |
+
color: #ef4444;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.toast-info i {
|
| 511 |
+
color: var(--cocoa-l);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
@keyframes toastIn {
|
| 515 |
+
from {
|
| 516 |
+
opacity: 0;
|
| 517 |
+
transform: translateY(20px);
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
to {
|
| 521 |
+
opacity: 1;
|
| 522 |
+
transform: translateY(0);
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
@keyframes toastOut {
|
| 527 |
+
from {
|
| 528 |
+
opacity: 1;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
to {
|
| 532 |
+
opacity: 0;
|
| 533 |
+
transform: translateY(20px);
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
/* ---- Stats empty overlay ---- */
|
| 538 |
+
.stats-empty-overlay {
|
| 539 |
+
position: absolute;
|
| 540 |
+
inset: 0;
|
| 541 |
+
z-index: 50;
|
| 542 |
+
display: flex;
|
| 543 |
+
flex-direction: column;
|
| 544 |
+
align-items: center;
|
| 545 |
+
justify-content: center;
|
| 546 |
+
background: rgba(10, 10, 10, 0.85);
|
| 547 |
+
backdrop-filter: blur(8px);
|
| 548 |
+
border-radius: 12px;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
/* ---- Feedback form ---- */
|
| 552 |
+
.fb-textarea {
|
| 553 |
+
background: #111;
|
| 554 |
+
border: 1px solid #2a2a2a;
|
| 555 |
+
border-radius: 8px;
|
| 556 |
+
color: #f0ece6;
|
| 557 |
+
font-size: 12px;
|
| 558 |
+
padding: 12px;
|
| 559 |
+
width: 100%;
|
| 560 |
+
min-height: 120px;
|
| 561 |
+
resize: vertical;
|
| 562 |
+
font-family: 'Inter', sans-serif;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.fb-textarea:focus {
|
| 566 |
+
outline: none;
|
| 567 |
+
border-color: var(--cocoa-l);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.fb-select {
|
| 571 |
+
background: #111;
|
| 572 |
+
border: 1px solid #2a2a2a;
|
| 573 |
+
border-radius: 8px;
|
| 574 |
+
color: #f0ece6;
|
| 575 |
+
font-size: 11px;
|
| 576 |
+
padding: 10px 12px;
|
| 577 |
+
/* taller for touch */
|
| 578 |
+
width: 100%;
|
| 579 |
+
font-family: 'Inter', sans-serif;
|
| 580 |
+
min-height: 44px;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.fb-select:focus {
|
| 584 |
+
outline: none;
|
| 585 |
+
border-color: var(--cocoa-l);
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.fb-stars {
|
| 589 |
+
display: flex;
|
| 590 |
+
gap: 8px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.fb-star {
|
| 594 |
+
font-size: 28px;
|
| 595 |
+
/* larger for mobile tapping */
|
| 596 |
+
color: #333;
|
| 597 |
+
cursor: pointer;
|
| 598 |
+
transition: color 0.15s;
|
| 599 |
+
min-width: 36px;
|
| 600 |
+
min-height: 36px;
|
| 601 |
+
display: flex;
|
| 602 |
+
align-items: center;
|
| 603 |
+
justify-content: center;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.fb-star.active,
|
| 607 |
+
.fb-star:hover {
|
| 608 |
+
color: var(--cocoa-l);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.fb-chip {
|
| 612 |
+
background: #050505;
|
| 613 |
+
border: 1px solid #222;
|
| 614 |
+
border-radius: 8px;
|
| 615 |
+
color: #666;
|
| 616 |
+
font-size: 10px;
|
| 617 |
+
font-weight: 700;
|
| 618 |
+
padding: 14px 12px;
|
| 619 |
+
/* taller for touch */
|
| 620 |
+
cursor: pointer;
|
| 621 |
+
transition: all 0.2s ease;
|
| 622 |
+
text-align: center;
|
| 623 |
+
text-transform: uppercase;
|
| 624 |
+
min-height: 44px;
|
| 625 |
+
display: flex;
|
| 626 |
+
align-items: center;
|
| 627 |
+
justify-content: center;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.fb-chip:hover {
|
| 631 |
+
border-color: #444;
|
| 632 |
+
color: #999;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.fb-chip.active {
|
| 636 |
+
border-color: var(--cocoa-l);
|
| 637 |
+
background: #111;
|
| 638 |
+
color: #fff;
|
| 639 |
+
box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.fb-emoji-btn {
|
| 643 |
+
background: #111;
|
| 644 |
+
border: 1px solid #2a2a2a;
|
| 645 |
+
border-radius: 8px;
|
| 646 |
+
color: #555;
|
| 647 |
+
flex: 1;
|
| 648 |
+
text-align: center;
|
| 649 |
+
padding: 12px 4px;
|
| 650 |
+
/* taller */
|
| 651 |
+
cursor: pointer;
|
| 652 |
+
transition: all 0.2s ease;
|
| 653 |
+
min-height: 64px;
|
| 654 |
+
display: flex;
|
| 655 |
+
flex-direction: column;
|
| 656 |
+
align-items: center;
|
| 657 |
+
justify-content: center;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.fb-emoji-btn:hover {
|
| 661 |
+
border-color: #444;
|
| 662 |
+
color: #888;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.fb-emoji-btn.active {
|
| 666 |
+
border-color: var(--cocoa-l);
|
| 667 |
+
background: #1a1a1a;
|
| 668 |
+
color: var(--cocoa-l);
|
| 669 |
+
box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
/* =============================================
|
| 673 |
+
DESKTOP (≥1024px) — original layout intact
|
| 674 |
+
============================================= */
|
| 675 |
+
@media (min-width: 1024px) {
|
| 676 |
+
|
| 677 |
+
/* Sidebar visible */
|
| 678 |
+
aside.w-60 {
|
| 679 |
+
display: flex !important;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
/* Top mobile nav hidden */
|
| 683 |
+
.mobile-nav {
|
| 684 |
+
display: none !important;
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
/* Bottom mobile nav hidden */
|
| 688 |
+
.mobile-bottom-nav {
|
| 689 |
+
display: none !important;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
/* Main — no bottom padding needed */
|
| 693 |
+
main {
|
| 694 |
+
padding-bottom: 1rem !important;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
/* Toast — desktop position: bottom-right */
|
| 698 |
+
#toast-container {
|
| 699 |
+
bottom: 20px;
|
| 700 |
+
left: unset;
|
| 701 |
+
right: 20px;
|
| 702 |
+
transform: none;
|
| 703 |
+
width: auto;
|
| 704 |
+
align-items: flex-end;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
/* Settings — 2 column grid */
|
| 708 |
+
#tab-settings .grid {
|
| 709 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
/* Run details — multi-column grids preserved */
|
| 713 |
+
#run-results-content {
|
| 714 |
+
grid-template-columns: repeat(3, 1fr) !important;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.grid-cols-2 {
|
| 718 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
.grid-cols-3 {
|
| 722 |
+
grid-template-columns: repeat(3, 1fr) !important;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
/* Reports grid */
|
| 726 |
+
#reports-grid,
|
| 727 |
+
#reports-pending {
|
| 728 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
/* About grid */
|
| 732 |
+
#tab-about .grid.grid-cols-3 {
|
| 733 |
+
grid-template-columns: repeat(3, 1fr) !important;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
/* Insights panel */
|
| 739 |
+
#insights-panel .grid {
|
| 740 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
/* =============================================
|
| 745 |
+
MOBILE (< 1024px) — full mobile overhaul
|
| 746 |
+
============================================= */
|
| 747 |
+
@media (max-width: 1023px) {
|
| 748 |
+
|
| 749 |
+
/* --- Hide desktop sidebar --- */
|
| 750 |
+
aside.w-60 {
|
| 751 |
+
display: none !important;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
/* --- Hide old top mobile nav bar --- */
|
| 755 |
+
.mobile-nav {
|
| 756 |
+
display: none !important;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/* --- Body layout --- */
|
| 760 |
+
body {
|
| 761 |
+
height: 100dvh;
|
| 762 |
+
/* dynamic viewport height — accounts for mobile browser chrome */
|
| 763 |
+
overflow: hidden;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
/* --- Main content — room for top and bottom nav --- */
|
| 767 |
+
main {
|
| 768 |
+
padding: 70px 12px calc(var(--mob-nav-h) + 8px) 12px !important;
|
| 769 |
+
gap: 12px !important;
|
| 770 |
+
display: flex !important;
|
| 771 |
+
flex-direction: column !important;
|
| 772 |
+
height: 100dvh !important;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
/* --- Tab Scrolling Fixes — force flex-1 to push progress bar down --- */
|
| 776 |
+
#tab-about,
|
| 777 |
+
#tab-overview,
|
| 778 |
+
#tab-run-details,
|
| 779 |
+
#tab-reports,
|
| 780 |
+
#tab-settings,
|
| 781 |
+
#tab-help,
|
| 782 |
+
#tab-feedback {
|
| 783 |
+
flex: 1 !important;
|
| 784 |
+
min-height: 0 !important;
|
| 785 |
+
padding-bottom: 20px !important;
|
| 786 |
+
overscroll-behavior: contain;
|
| 787 |
+
-webkit-overflow-scrolling: touch;
|
| 788 |
+
overflow-y: auto !important;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
/* --- About tab specific spacing --- */
|
| 792 |
+
#tab-about .space-y-8 {
|
| 793 |
+
gap: 16px !important;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
#tab-about .pt-8 {
|
| 797 |
+
padding-top: 16px !important;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
#tab-overview:not(.hidden) {
|
| 801 |
+
display: flex !important;
|
| 802 |
+
flex-direction: column !important;
|
| 803 |
+
overflow-y: auto !important;
|
| 804 |
+
overflow-x: hidden !important;
|
| 805 |
+
-webkit-overflow-scrolling: touch;
|
| 806 |
+
overscroll-behavior: contain;
|
| 807 |
+
padding-bottom: calc(var(--mob-nav-h) + 24px) !important;
|
| 808 |
+
gap: 16px !important;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
#tab-overview>div:not(#stats-empty-state) {
|
| 812 |
+
grid-column: span 1 !important;
|
| 813 |
+
min-height: 280px;
|
| 814 |
+
flex-shrink: 0;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.stats-empty-overlay {
|
| 818 |
+
position: fixed !important;
|
| 819 |
+
top: 58px;
|
| 820 |
+
/* below mobile top bar */
|
| 821 |
+
left: 0;
|
| 822 |
+
right: 0;
|
| 823 |
+
bottom: var(--mob-nav-h);
|
| 824 |
+
height: auto !important;
|
| 825 |
+
z-index: 100;
|
| 826 |
+
background: rgba(0, 0, 0, 0.98);
|
| 827 |
+
display: flex;
|
| 828 |
+
flex-direction: column;
|
| 829 |
+
align-items: center;
|
| 830 |
+
justify-content: center;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
/* CRITICAL: hide overlay when its parent tab is hidden */
|
| 834 |
+
#tab-overview.hidden .stats-empty-overlay {
|
| 835 |
+
display: none !important;
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
/* Hide charts when curtain is up to prevent scroll jank */
|
| 839 |
+
#tab-overview.curtain-active {
|
| 840 |
+
overflow: hidden !important;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
/* --- Settings tab — tighter layout --- */
|
| 844 |
+
#tab-settings>div[class*="grid"] {
|
| 845 |
+
display: flex !important;
|
| 846 |
+
flex-direction: column !important;
|
| 847 |
+
gap: 12px !important;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
#tab-settings {
|
| 851 |
+
overflow-x: hidden !important;
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
/* Collapse chip panel completely when not shown — removes gap */
|
| 855 |
+
#chip-selector.hidden-chip-container {
|
| 856 |
+
display: none !important;
|
| 857 |
+
margin: 0 !important;
|
| 858 |
+
padding: 0 !important;
|
| 859 |
+
height: 0 !important;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
/* When visible, give it breathing room */
|
| 863 |
+
#chip-selector:not(.hidden-chip-container) {
|
| 864 |
+
display: flex !important;
|
| 865 |
+
flex-wrap: wrap !important;
|
| 866 |
+
gap: 8px !important;
|
| 867 |
+
margin-top: 12px !important;
|
| 868 |
+
padding: 0 !important;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
/* Ensure all s-row items are uniform flex rows */
|
| 872 |
+
.s-row {
|
| 873 |
+
display: flex !important;
|
| 874 |
+
flex-direction: row !important;
|
| 875 |
+
flex-wrap: nowrap !important;
|
| 876 |
+
align-items: center !important;
|
| 877 |
+
justify-content: space-between !important;
|
| 878 |
+
padding: 14px 0 !important;
|
| 879 |
+
border-bottom: 1px solid #1a1a1a !important;
|
| 880 |
+
gap: 12px !important;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.s-row:last-child {
|
| 884 |
+
border-bottom: none !important;
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
/* Never let mobile flex override Tailwind .hidden utility */
|
| 888 |
+
.s-row.hidden {
|
| 889 |
+
display: none !important;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
/* chip-selector sits as a sibling below the annotated s-row on mobile */
|
| 893 |
+
#chip-selector:not(.hidden-chip-container) {
|
| 894 |
+
margin-top: 0 !important;
|
| 895 |
+
border-top: none !important;
|
| 896 |
+
padding-top: 0 !important;
|
| 897 |
+
padding-bottom: 12px !important;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
/* Lock toggle in annotated row — must never wrap or shrink */
|
| 901 |
+
.s-row[data-param="annotated"]>.toggle-track {
|
| 902 |
+
flex-shrink: 0 !important;
|
| 903 |
+
flex-grow: 0 !important;
|
| 904 |
+
flex-basis: 36px !important;
|
| 905 |
+
width: 36px !important;
|
| 906 |
+
min-width: 36px !important;
|
| 907 |
+
align-self: center !important;
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
/* Label side must absorb remaining space and never overflow */
|
| 911 |
+
.s-row[data-param="annotated"]>div:first-child {
|
| 912 |
+
flex: 1 1 0 !important;
|
| 913 |
+
min-width: 0 !important;
|
| 914 |
+
overflow: hidden !important;
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.s-stepper {
|
| 918 |
+
width: 140px !important;
|
| 919 |
+
/* Compact fixed width */
|
| 920 |
+
scale: 0.9;
|
| 921 |
+
transform-origin: right;
|
| 922 |
+
display: inline-flex !important;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.toggle-track {
|
| 926 |
+
width: 36px !important;
|
| 927 |
+
scale: 0.9;
|
| 928 |
+
transform-origin: right;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
@media (max-width: 480px) {
|
| 932 |
+
.s-row {
|
| 933 |
+
flex-direction: row !important;
|
| 934 |
+
flex-wrap: nowrap !important;
|
| 935 |
+
align-items: center !important;
|
| 936 |
+
justify-content: space-between !important;
|
| 937 |
+
gap: 12px !important;
|
| 938 |
+
padding: 10px 16px !important;
|
| 939 |
+
width: 100% !important;
|
| 940 |
+
box-sizing: border-box !important;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
/* chip panel below annotated row — remove extra top gap */
|
| 944 |
+
#chip-selector:not(.hidden-chip-container) {
|
| 945 |
+
margin-top: 0 !important;
|
| 946 |
+
padding-bottom: 12px !important;
|
| 947 |
+
padding-left: 16px !important;
|
| 948 |
+
padding-right: 16px !important;
|
| 949 |
+
border-top: none !important;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
.s-row {
|
| 953 |
+
padding: 12px 16px !important;
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
#tab-run-details .p-8 {
|
| 957 |
+
padding: 20px !important;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
#run-results-content {
|
| 961 |
+
grid-template-columns: 1fr !important;
|
| 962 |
+
gap: 16px !important;
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
#panel-video .flex,
|
| 966 |
+
#panel-perf .flex,
|
| 967 |
+
#panel-model .flex,
|
| 968 |
+
#panel-infer .flex {
|
| 969 |
+
padding-bottom: 8px !important;
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
.s-row .info-wrap {
|
| 973 |
+
display: inline-flex !important;
|
| 974 |
+
vertical-align: middle;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
.s-row>div:first-child {
|
| 978 |
+
width: auto !important;
|
| 979 |
+
max-width: 75% !important;
|
| 980 |
+
flex: 1 !important;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.toggle-track {
|
| 984 |
+
width: 36px !important;
|
| 985 |
+
min-width: 36px !important;
|
| 986 |
+
height: 20px !important;
|
| 987 |
+
flex-shrink: 0 !important;
|
| 988 |
+
display: block !important;
|
| 989 |
+
position: relative !important;
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
#run-results-card .text-[10px] {
|
| 993 |
+
font-size: 9px !important;
|
| 994 |
+
letter-spacing: 0.05em !important;
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.s-row>.s-stepper {
|
| 998 |
+
width: 130px !important;
|
| 999 |
+
flex-shrink: 0 !important;
|
| 1000 |
+
display: inline-flex !important;
|
| 1001 |
+
flex-direction: row !important;
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
.chip-container {
|
| 1005 |
+
display: grid !important;
|
| 1006 |
+
grid-template-columns: 1fr 1fr !important;
|
| 1007 |
+
gap: 6px !important;
|
| 1008 |
+
margin-top: 12px !important;
|
| 1009 |
+
padding: 10px !important;
|
| 1010 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1011 |
+
border-radius: 8px;
|
| 1012 |
+
border: 1px solid #1a1a1a;
|
| 1013 |
+
width: 100% !important;
|
| 1014 |
+
box-sizing: border-box !important;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
.chip {
|
| 1018 |
+
padding: 6px !important;
|
| 1019 |
+
font-size: 9px !important;
|
| 1020 |
+
min-height: 32px !important;
|
| 1021 |
+
border-radius: 6px !important;
|
| 1022 |
+
justify-content: center !important;
|
| 1023 |
+
width: 100% !important;
|
| 1024 |
+
white-space: nowrap !important;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
.s-stepper {
|
| 1028 |
+
width: 130px !important;
|
| 1029 |
+
min-width: 130px !important;
|
| 1030 |
+
display: inline-flex !important;
|
| 1031 |
+
flex-direction: row !important;
|
| 1032 |
+
align-items: center !important;
|
| 1033 |
+
justify-content: space-between !important;
|
| 1034 |
+
transform-origin: right !important;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
.toggle-track {
|
| 1038 |
+
transform-origin: right !important;
|
| 1039 |
+
}
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
/* --- Progress bar wrapper — remove extra margin to fix huge gap --- */
|
| 1043 |
+
#progress-bar-wrapper {
|
| 1044 |
+
width: 100% !important;
|
| 1045 |
+
max-width: 100% !important;
|
| 1046 |
+
box-sizing: border-box !important;
|
| 1047 |
+
margin-top: auto !important;
|
| 1048 |
+
margin-bottom: 4px !important;
|
| 1049 |
+
padding: 8px 12px !important;
|
| 1050 |
+
flex-direction: column !important;
|
| 1051 |
+
align-items: flex-start !important;
|
| 1052 |
+
gap: 6px !important;
|
| 1053 |
+
position: relative;
|
| 1054 |
+
z-index: 10;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
#progress-bar-wrapper>div:first-child {
|
| 1058 |
+
width: 100% !important;
|
| 1059 |
+
flex: 1 !important;
|
| 1060 |
+
min-width: 0 !important;
|
| 1061 |
+
margin-right: 0 !important;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
#progress-bar-wrapper>div:last-child {
|
| 1065 |
+
width: 100% !important;
|
| 1066 |
+
justify-content: space-between !important;
|
| 1067 |
+
font-size: 10px !important;
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
/* --- All other grids collapse to single column --- */
|
| 1071 |
+
.grid-cols-3,
|
| 1072 |
+
.grid-cols-2,
|
| 1073 |
+
.lg\:grid-cols-2,
|
| 1074 |
+
.xl\:grid-cols-3 {
|
| 1075 |
+
grid-template-columns: 1fr !important;
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
/* --- Run details tab --- */
|
| 1079 |
+
#run-results-content {
|
| 1080 |
+
grid-template-columns: 1fr !important;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
#tab-run-details .grid-cols-2,
|
| 1084 |
+
#tab-run-details .grid-cols-3 {
|
| 1085 |
+
grid-template-columns: 1fr !important;
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
/* --- Reports grid --- */
|
| 1089 |
+
#reports-grid,
|
| 1090 |
+
#reports-pending {
|
| 1091 |
+
grid-template-columns: 1fr !important;
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
/* --- About tab grid --- */
|
| 1095 |
+
#tab-about .grid.grid-cols-3 {
|
| 1096 |
+
grid-template-columns: 1fr !important;
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
/* --- Post-process cards --- */
|
| 1100 |
+
#post-process-cards {
|
| 1101 |
+
grid-template-columns: 1fr !important;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
/* --- Insights panel --- */
|
| 1105 |
+
#insights-panel .grid {
|
| 1106 |
+
grid-template-columns: 1fr !important;
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
/* --- Feedback tab --- */
|
| 1110 |
+
#tab-feedback .grid {
|
| 1111 |
+
grid-template-columns: 1fr !important;
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
/* --- About tab cards --- */
|
| 1115 |
+
#tab-about .bg-black.border.rounded-xl {
|
| 1116 |
+
padding: 20px !important;
|
| 1117 |
+
}
|
| 1118 |
+
|
| 1119 |
+
/* --- Stepper — ensure full tap area --- */
|
| 1120 |
+
.s-stepper button {
|
| 1121 |
+
padding: 10px 14px;
|
| 1122 |
+
min-width: 40px;
|
| 1123 |
+
min-height: 40px;
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
/* --- s-row label text — allow wrap --- */
|
| 1127 |
+
.s-row>div:first-child .text-xs {
|
| 1128 |
+
font-size: 11px;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
/* --- Help accordion buttons --- */
|
| 1132 |
+
#tab-help button.w-full {
|
| 1133 |
+
min-height: 52px;
|
| 1134 |
+
padding: 14px 16px !important;
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
/* --- Feedback priority chips grid --- */
|
| 1138 |
+
#fb-priorities {
|
| 1139 |
+
grid-template-columns: 1fr !important;
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
/* --- Keyboard shortcut modal --- */
|
| 1143 |
+
#appModal-shortcutsModal>div {
|
| 1144 |
+
max-width: 95% !important;
|
| 1145 |
+
padding: 20px !important;
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
/* --- Privacy / Terms modals --- */
|
| 1149 |
+
[id^="appModal-"]>div {
|
| 1150 |
+
max-width: 95% !important;
|
| 1151 |
+
max-height: 80dvh !important;
|
| 1152 |
+
overflow-y: auto !important;
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
#tab-overview>div:last-child {
|
| 1156 |
+
min-height: 300px !important;
|
| 1157 |
+
padding-bottom: 4px !important;
|
| 1158 |
+
margin-bottom: 0 !important;
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
/* --- Vehicle Classification Internal Scroll --- */
|
| 1162 |
+
#tab-overview>div:nth-child(4) {
|
| 1163 |
+
max-height: 380px !important;
|
| 1164 |
+
display: flex !important;
|
| 1165 |
+
flex-direction: column !important;
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
#tab-overview>div:nth-child(4) #class-breakdown {
|
| 1169 |
+
flex: 1 !important;
|
| 1170 |
+
overflow-y: auto !important;
|
| 1171 |
+
min-height: 0 !important;
|
| 1172 |
+
}
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
/* =============================================
|
| 1176 |
+
BOTTOM NAVIGATION BAR — mobile only
|
| 1177 |
+
============================================= */
|
| 1178 |
+
.mobile-bottom-nav {
|
| 1179 |
+
display: none;
|
| 1180 |
+
/* hidden by default, shown on mobile */
|
| 1181 |
+
position: fixed;
|
| 1182 |
+
bottom: 0;
|
| 1183 |
+
left: 0;
|
| 1184 |
+
right: 0;
|
| 1185 |
+
height: 68px;
|
| 1186 |
+
background: #000000;
|
| 1187 |
+
border-top: 1px solid #1a1a1a;
|
| 1188 |
+
z-index: 40;
|
| 1189 |
+
align-items: stretch;
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
.mob-nav-item {
|
| 1193 |
+
flex: 1;
|
| 1194 |
+
display: flex;
|
| 1195 |
+
flex-direction: column;
|
| 1196 |
+
align-items: center;
|
| 1197 |
+
justify-content: center;
|
| 1198 |
+
gap: 3px;
|
| 1199 |
+
cursor: pointer;
|
| 1200 |
+
color: #444444;
|
| 1201 |
+
font-size: 0;
|
| 1202 |
+
font-weight: 700;
|
| 1203 |
+
text-transform: uppercase;
|
| 1204 |
+
letter-spacing: 0.05em;
|
| 1205 |
+
transition: color 0.15s ease;
|
| 1206 |
+
border: none;
|
| 1207 |
+
background: none;
|
| 1208 |
+
padding: 8px 2px;
|
| 1209 |
+
-webkit-tap-highlight-color: transparent;
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
.mob-nav-item i {
|
| 1213 |
+
font-size: 22px;
|
| 1214 |
+
transition: color 0.15s ease;
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
.mob-nav-item.active {
|
| 1218 |
+
color: var(--cocoa-l);
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
.mob-nav-item.active i {
|
| 1222 |
+
color: var(--cocoa-l);
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
+
.mob-nav-item:active {
|
| 1226 |
+
color: var(--cocoa-xl);
|
| 1227 |
+
}
|
| 1228 |
+
|
| 1229 |
+
/* Show bottom nav only on mobile */
|
| 1230 |
+
@media (max-width: 1023px) {
|
| 1231 |
+
.mobile-bottom-nav {
|
| 1232 |
+
display: flex !important;
|
| 1233 |
+
}
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
/* =============================================
|
| 1237 |
+
MEDIUM TABLET (640px–1023px) adjustments
|
| 1238 |
+
============================================= */
|
| 1239 |
+
@media (min-width: 640px) and (max-width: 1023px) {
|
| 1240 |
+
|
| 1241 |
+
/* 2-column grids on tablet where it fits */
|
| 1242 |
+
#tab-overview>div {
|
| 1243 |
+
min-height: 280px;
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
#reports-grid,
|
| 1247 |
+
#reports-pending {
|
| 1248 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
#fb-priorities {
|
| 1252 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
#tab-about .grid.grid-cols-3 {
|
| 1256 |
+
grid-template-columns: repeat(2, 1fr) !important;
|
| 1257 |
+
}
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
/* =============================================
|
| 1261 |
+
TOUCH DEVICES — remove hover jank
|
| 1262 |
+
============================================= */
|
| 1263 |
+
@media (hover: none) and (pointer: coarse) {
|
| 1264 |
+
.nav-item-inactive:hover {
|
| 1265 |
+
color: #555555 !important;
|
| 1266 |
+
background-color: transparent !important;
|
| 1267 |
+
}
|
| 1268 |
+
|
| 1269 |
+
.chip:hover {
|
| 1270 |
+
border-color: #333333;
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
.chip.active:hover {
|
| 1274 |
+
background: var(--cocoa-l);
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
.s-stepper button:hover {
|
| 1278 |
+
background: transparent;
|
| 1279 |
+
color: #666666;
|
| 1280 |
+
}
|
| 1281 |
+
|
| 1282 |
+
/* Make all interactive elements minimum 44px tall */
|
| 1283 |
+
button,
|
| 1284 |
+
.fb-emoji-btn,
|
| 1285 |
+
.mob-nav-item {
|
| 1286 |
+
min-height: 44px;
|
| 1287 |
+
}
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
/* ============================================================
|
| 1291 |
+
Custom Select Dropdown (uf-select)
|
| 1292 |
+
Replaces native <select> to prevent OS picker sheet on mobile
|
| 1293 |
+
============================================================ */
|
| 1294 |
+
.uf-select-wrap {
|
| 1295 |
+
position: relative;
|
| 1296 |
+
display: inline-block;
|
| 1297 |
+
min-width: 110px;
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
.uf-select-wrap.w-full {
|
| 1301 |
+
display: block;
|
| 1302 |
+
width: 100%;
|
| 1303 |
+
}
|
| 1304 |
+
|
| 1305 |
+
.uf-select-trigger {
|
| 1306 |
+
display: flex;
|
| 1307 |
+
align-items: center;
|
| 1308 |
+
justify-content: space-between;
|
| 1309 |
+
gap: 6px;
|
| 1310 |
+
padding: 5px 10px;
|
| 1311 |
+
background: #111111;
|
| 1312 |
+
border: 1px solid #222222;
|
| 1313 |
+
border-radius: 6px;
|
| 1314 |
+
font-size: 11px;
|
| 1315 |
+
font-weight: 600;
|
| 1316 |
+
color: #ffffff;
|
| 1317 |
+
cursor: pointer;
|
| 1318 |
+
user-select: none;
|
| 1319 |
+
-webkit-tap-highlight-color: transparent;
|
| 1320 |
+
transition: border-color 0.15s;
|
| 1321 |
+
white-space: nowrap;
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
.uf-select-trigger:hover,
|
| 1325 |
+
.uf-select-trigger:active {
|
| 1326 |
+
border-color: #444444;
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
.uf-select-arrow {
|
| 1330 |
+
font-size: 9px;
|
| 1331 |
+
color: #666666;
|
| 1332 |
+
transition: transform 0.2s ease;
|
| 1333 |
+
flex-shrink: 0;
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
.uf-select-arrow-open {
|
| 1337 |
+
transform: rotate(180deg);
|
| 1338 |
+
}
|
| 1339 |
+
|
| 1340 |
+
/* Dropdown panel — opens downward by default */
|
| 1341 |
+
.uf-select-dropdown {
|
| 1342 |
+
position: absolute;
|
| 1343 |
+
top: calc(100% + 4px);
|
| 1344 |
+
left: 0;
|
| 1345 |
+
min-width: 100%;
|
| 1346 |
+
background: #111111;
|
| 1347 |
+
border: 1px solid #2a2a2a;
|
| 1348 |
+
border-radius: 8px;
|
| 1349 |
+
z-index: 9999;
|
| 1350 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
|
| 1351 |
+
overflow: hidden;
|
| 1352 |
+
max-height: 240px;
|
| 1353 |
+
overflow-y: auto;
|
| 1354 |
+
}
|
| 1355 |
+
|
| 1356 |
+
/* Upward variant — anchors above trigger, for bottom-of-screen selects */
|
| 1357 |
+
.uf-select-dropdown-up {
|
| 1358 |
+
top: auto;
|
| 1359 |
+
bottom: calc(100% + 4px);
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
.uf-select-option {
|
| 1363 |
+
padding: 10px 14px;
|
| 1364 |
+
font-size: 11px;
|
| 1365 |
+
font-weight: 600;
|
| 1366 |
+
color: #aaaaaa;
|
| 1367 |
+
cursor: pointer;
|
| 1368 |
+
transition: background 0.1s, color 0.1s;
|
| 1369 |
+
-webkit-tap-highlight-color: transparent;
|
| 1370 |
+
}
|
| 1371 |
+
|
| 1372 |
+
.uf-select-option:hover,
|
| 1373 |
+
.uf-select-option:active {
|
| 1374 |
+
background: #1a1a1a;
|
| 1375 |
+
color: #ffffff;
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
.uf-select-option-active {
|
| 1379 |
+
color: var(--cocoa-l);
|
| 1380 |
+
background: #0a0a0a;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
/* Hide scrollbar inside dropdown — options fit within max-height */
|
| 1384 |
+
.uf-select-dropdown::-webkit-scrollbar {
|
| 1385 |
+
width: 0;
|
| 1386 |
+
height: 0;
|
| 1387 |
+
}
|
| 1388 |
+
|
| 1389 |
+
/* Desktop: Vehicle Classification thin grey scrollbar (matches reference) */
|
| 1390 |
+
@media (min-width: 1024px) {
|
| 1391 |
+
#class-breakdown::-webkit-scrollbar {
|
| 1392 |
+
width: 4px;
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
#class-breakdown::-webkit-scrollbar-track {
|
| 1396 |
+
background: #000000;
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
#class-breakdown::-webkit-scrollbar-thumb {
|
| 1400 |
+
background: #333333;
|
| 1401 |
+
border-radius: 4px;
|
| 1402 |
+
}
|
| 1403 |
+
|
| 1404 |
+
#class-breakdown::-webkit-scrollbar-thumb:hover {
|
| 1405 |
+
background: #444444;
|
| 1406 |
+
}
|
| 1407 |
+
}
|
| 1408 |
+
|
| 1409 |
+
/* ---- Profile & Sidebar PFP ---- */
|
| 1410 |
+
#sidebar-profile-pfp-wrap img,
|
| 1411 |
+
#mob-pfp-wrap img {
|
| 1412 |
+
width: 100%;
|
| 1413 |
+
height: 100%;
|
| 1414 |
+
object-fit: cover;
|
| 1415 |
+
border-radius: 50%;
|
| 1416 |
+
}
|
| 1417 |
+
|
| 1418 |
+
/* Fix fallback icon visibility (parent font-size is 0) */
|
| 1419 |
+
#sidebar-profile-pfp-wrap i,
|
| 1420 |
+
#mob-pfp-wrap i {
|
| 1421 |
+
font-size: 1.2rem;
|
| 1422 |
+
color: #555;
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
+
.mob-nav-item i {
|
| 1426 |
+
transition: transform 0.2s ease;
|
| 1427 |
+
}
|
| 1428 |
+
|
| 1429 |
+
.mob-nav-item:active i {
|
| 1430 |
+
transform: scale(0.9);
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
/* ---- Legal Menu Dropdown ---- */
|
| 1434 |
+
#legal-menu, #legal-menu-profile {
|
| 1435 |
+
animation: menuFadeIn 0.2s ease-out forwards;
|
| 1436 |
+
transform-origin: top right;
|
| 1437 |
+
box-shadow: 0 10px 40px rgba(0,0,0,0.8);
|
| 1438 |
+
}
|
| 1439 |
+
|
| 1440 |
+
@keyframes menuFadeIn {
|
| 1441 |
+
from { opacity: 0; transform: translateY(-10px) scale(0.95); }
|
| 1442 |
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
/* ---- Profile Tab specific overrides ---- */
|
| 1446 |
+
#tab-profile input[type="text"] {
|
| 1447 |
+
border: 1px solid #222 !important;
|
| 1448 |
+
}
|
| 1449 |
+
|
| 1450 |
+
#tab-profile input[type="text"]:focus {
|
| 1451 |
+
border-color: var(--cocoa-l) !important;
|
| 1452 |
+
background: #000 !important;
|
| 1453 |
+
}
|
| 1454 |
+
|
| 1455 |
+
@media (max-width: 1023px) {
|
| 1456 |
+
/* Zero tab side padding on mobile */
|
| 1457 |
+
#tab-results,
|
| 1458 |
+
#tab-profile,
|
| 1459 |
+
#tab-overview {
|
| 1460 |
+
padding-left: 0 !important;
|
| 1461 |
+
padding-right: 0 !important;
|
| 1462 |
+
padding-bottom: calc(var(--mob-nav-h) + 12px) !important;
|
| 1463 |
+
}
|
| 1464 |
+
|
| 1465 |
+
/* Results content wrap — tighter spacing */
|
| 1466 |
+
#results-content-wrap {
|
| 1467 |
+
gap: 16px !important;
|
| 1468 |
+
}
|
| 1469 |
+
|
| 1470 |
+
/* Telemetry cards inner padding */
|
| 1471 |
+
#run-results-card > div.p-8 {
|
| 1472 |
+
padding: 12px !important;
|
| 1473 |
+
}
|
| 1474 |
+
#run-results-content {
|
| 1475 |
+
gap: 12px !important;
|
| 1476 |
+
}
|
| 1477 |
+
|
| 1478 |
+
/* Technical context grid — stack on mobile */
|
| 1479 |
+
#tab-results .grid.grid-cols-2 {
|
| 1480 |
+
grid-template-columns: 1fr !important;
|
| 1481 |
+
gap: 12px !important;
|
| 1482 |
+
}
|
| 1483 |
+
|
| 1484 |
+
/* Panel inner padding */
|
| 1485 |
+
#panel-video,
|
| 1486 |
+
#panel-perf,
|
| 1487 |
+
#panel-model,
|
| 1488 |
+
#panel-infer {
|
| 1489 |
+
padding: 12px !important;
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
/* Telemetry section — reduce top margin */
|
| 1493 |
+
#tab-results details .mt-8 {
|
| 1494 |
+
margin-top: 16px !important;
|
| 1495 |
+
}
|
| 1496 |
+
#tab-results .mt-12 {
|
| 1497 |
+
margin-top: 24px !important;
|
| 1498 |
+
padding-top: 24px !important;
|
| 1499 |
+
}
|
| 1500 |
+
|
| 1501 |
+
/* Profile card — tighter mobile padding */
|
| 1502 |
+
#tab-profile .bg-neutral-950 {
|
| 1503 |
+
border-radius: 12px !important;
|
| 1504 |
+
}
|
| 1505 |
+
#tab-profile .p-6 {
|
| 1506 |
+
padding: 16px !important;
|
| 1507 |
+
}
|
| 1508 |
+
#tab-profile .p-5 {
|
| 1509 |
+
padding: 14px !important;
|
| 1510 |
+
}
|
| 1511 |
+
|
| 1512 |
+
/* Chart cards — reduce padding */
|
| 1513 |
+
#tab-results .bg-black.rounded-xl {
|
| 1514 |
+
border-radius: 10px !important;
|
| 1515 |
+
}
|
| 1516 |
+
#tab-results .bg-neutral-950.rounded-xl {
|
| 1517 |
+
border-radius: 10px !important;
|
| 1518 |
+
}
|
| 1519 |
+
}
|
| 1520 |
+
|
| 1521 |
+
/* Ensure mobile results scrolling */
|
| 1522 |
+
#tab-results:not(.hidden) {
|
| 1523 |
+
display: flex !important;
|
| 1524 |
+
flex-direction: column !important;
|
| 1525 |
+
}
|
frontend/initial.html
CHANGED
|
@@ -23,11 +23,14 @@
|
|
| 23 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 24 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 25 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
|
|
| 26 |
<link rel="stylesheet" href="css/initial.css">
|
|
|
|
| 27 |
</head>
|
| 28 |
|
| 29 |
<body class="bg-black text-white min-h-screen w-full flex flex-col items-center selection:bg-white selection:text-black overflow-x-hidden">
|
| 30 |
|
|
|
|
| 31 |
<header class="mt-16 flex flex-col items-center flex-shrink-0 w-full z-10">
|
| 32 |
<img src="assets/uf_rf.png" alt="UrbanFlow Logo" class="h-44 md:h-52 w-auto object-contain mb-3">
|
| 33 |
</header>
|
|
@@ -44,22 +47,21 @@
|
|
| 44 |
</h1>
|
| 45 |
<p class="font-bold mb-8 text-sm uppercase tracking-[0.2em] flex items-center justify-center sm:justify-start" style="color:#a89f97">
|
| 46 |
<span class="core-badge px-3 py-1 rounded-full text-[10px] mr-3">Beta</span>
|
| 47 |
-
|
| 48 |
</p>
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
<
|
| 52 |
-
|
| 53 |
-
<
|
| 54 |
-
<
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
|
| 57 |
<div class="mt-10 flex justify-center sm:justify-start">
|
| 58 |
-
<button onclick="openOnboarding()" class="
|
| 59 |
-
|
| 60 |
-
<i class="fa-solid fa-play text-[10px] ml-0.5"></i>
|
| 61 |
-
</div>
|
| 62 |
-
<span class="uppercase tracking-widest text-[11px] font-bold" style="color:#c89a6c">Experience Guided Tour</span>
|
| 63 |
</button>
|
| 64 |
</div>
|
| 65 |
</div>
|
|
@@ -71,7 +73,7 @@
|
|
| 71 |
<h2 class="text-3xl font-bold mb-2 text-center" style="color:#f0ece6">UrbanFlow</h2>
|
| 72 |
<p class="text-[13px] font-medium mb-8 text-center" style="color:#a89f97">Proceed to analysis</p>
|
| 73 |
<div class="flex justify-center w-full">
|
| 74 |
-
<div onclick="
|
| 75 |
class="group relative border-2 rounded-[2rem] p-8 cursor-pointer hover:-translate-y-1 transition-all duration-300 text-center max-w-sm w-full traffic-dynamics-card">
|
| 76 |
<div class="absolute top-4 right-6 text-[9px] font-bold px-2.5 py-1 rounded-full uppercase tracking-wider"
|
| 77 |
style="background:#c89a6c;color:#000">BETA</div>
|
|
@@ -121,15 +123,19 @@
|
|
| 121 |
</div>
|
| 122 |
<canvas id="drawing-canvas" class="absolute inset-0 w-full h-full"></canvas>
|
| 123 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
<div class="flex flex-col items-center gap-3">
|
| 125 |
<button id="btn-proceed" onclick="startRun()"
|
| 126 |
class="w-fit px-16 py-3.5 rounded-full font-bold transition-all text-center text-sm shadow-lg hover:scale-105 active:scale-95"
|
| 127 |
-
style="background:#
|
| 128 |
Continue →
|
| 129 |
</button>
|
| 130 |
<button onclick="resetCanvas()"
|
| 131 |
-
class="text-[
|
| 132 |
-
style="
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
|
|
@@ -138,103 +144,44 @@
|
|
| 138 |
|
| 139 |
<footer class="w-full max-w-[90rem] mx-auto px-10 mt-auto z-10 text-[11px] font-bold uppercase tracking-[0.2em]" style="color:#777">
|
| 140 |
|
| 141 |
-
<
|
| 142 |
-
|
| 143 |
-
<
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
</div>
|
| 153 |
-
|
| 154 |
-
<!-- Mobile: Privacy Policy left | T&C right, then © centered below -->
|
| 155 |
-
<div class="md:hidden py-4">
|
| 156 |
-
<div class="flex items-center justify-between mb-2">
|
| 157 |
-
<button onclick="openAppModal('privacyModal')" class="hover:text-white transition">Privacy Policy</button>
|
| 158 |
-
<button onclick="openAppModal('termsModal')" class="hover:text-white transition">Terms & Conditions</button>
|
| 159 |
-
</div>
|
| 160 |
-
<div class="text-center">
|
| 161 |
-
© 2026 UrbanFlow. All rights reserved.
|
| 162 |
</div>
|
| 163 |
</div>
|
| 164 |
|
| 165 |
</footer>
|
| 166 |
|
|
|
|
|
|
|
|
|
|
| 167 |
<script src="js/initial.js"></script>
|
| 168 |
<script>
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
if (
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
-
|
| 178 |
-
|
|
|
|
| 179 |
});
|
| 180 |
</script>
|
| 181 |
|
| 182 |
-
<!-- Privacy Modal -->
|
| 183 |
-
<div id="appModal-privacyModal" onclick="if(event.target===this)closeAppModal('privacyModal')"
|
| 184 |
-
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
|
| 185 |
-
<div
|
| 186 |
-
style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
|
| 187 |
-
<button onclick="closeAppModal('privacyModal')"
|
| 188 |
-
style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">×</button>
|
| 189 |
-
<h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Privacy Policy</h2>
|
| 190 |
-
<p style="color:#a89f97;font-size:11px;margin-bottom:20px">We keep this simple and honest.</p>
|
| 191 |
-
<ul style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;text-align:left">
|
| 192 |
-
<li>This is a <strong style="color:#f0ece6">public demo</strong> hosted on Hugging Face Spaces. It is not yet a complete production service.</li>
|
| 193 |
-
<li>UrbanFlow provides an estimated accuracy of ±5–8% on dense mixed-traffic footage. Results may vary across runs due to the nature of real-time frame-by-frame inference.</li>
|
| 194 |
-
<li>Footage you submit is processed with minimal delay and <strong style="color:#f0ece6">discarded immediately</strong> after the session ends. Nothing is stored on our servers.</li>
|
| 195 |
-
<li>We do not use your footage to train models, sell it, or share it with any third party.</li>
|
| 196 |
-
<li>Reports and annotated videos are generated temporarily and delivered to your device. We do not retain copies.</li>
|
| 197 |
-
<li>We do not use advertising cookies, behavioral tracking, or analytics scripts on this platform.</li>
|
| 198 |
-
<li>Your use of this demo may inform product requirements. No personally identifiable data is collected in that process.</li>
|
| 199 |
-
<li>For any queries: <strong style="color:#c89a6c">support.urbanflow365@gmail.com</strong></li>
|
| 200 |
-
</ul>
|
| 201 |
-
<p style="color:#555;font-size:10px;margin-top:20px;text-align:left">— Team UrbanFlow</p>
|
| 202 |
-
</div>
|
| 203 |
-
</div>
|
| 204 |
|
| 205 |
-
<!-- Terms Modal -->
|
| 206 |
-
<div id="appModal-termsModal" onclick="if(event.target===this)closeAppModal('termsModal')"
|
| 207 |
-
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
|
| 208 |
-
<div
|
| 209 |
-
style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
|
| 210 |
-
<button onclick="closeAppModal('termsModal')"
|
| 211 |
-
style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">×</button>
|
| 212 |
-
<h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Terms & Conditions</h2>
|
| 213 |
-
<p style="color:#a89f97;font-size:11px;margin-bottom:20px">By using this application, you agree to the
|
| 214 |
-
following terms.</p>
|
| 215 |
-
<p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px;text-align:left">You can:</p>
|
| 216 |
-
<ul
|
| 217 |
-
style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px;text-align:left">
|
| 218 |
-
<li>Use this application to evaluate UrbanFlow’s traffic detection and analytics capabilities.</li>
|
| 219 |
-
<li>Export reports, annotated video outputs, and data artifacts to your own device.</li>
|
| 220 |
-
<li>Share feedback, feature requests, or questions with us at <strong style="color:#c89a6c">support.urbanflow365@gmail.com</strong>.</li>
|
| 221 |
-
<li>Reference this application in research or internal evaluation, with proper attribution.</li>
|
| 222 |
-
</ul>
|
| 223 |
-
<p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px;text-align:left">You cannot:</p>
|
| 224 |
-
<ul
|
| 225 |
-
style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px;text-align:left">
|
| 226 |
-
<li>Commercially redistribute outputs or present them as your own product’s capability.</li>
|
| 227 |
-
<li>Reverse-engineer, extract, or attempt to replicate the underlying model or processing pipeline.</li>
|
| 228 |
-
<li>Use the application for unlawful, harmful, or safety-critical operational purposes.</li>
|
| 229 |
-
<li>Misrepresent outputs as certified or regulatory-grade traffic data.</li>
|
| 230 |
-
</ul>
|
| 231 |
-
<p style="color:#a89f97;font-size:11px;text-align:left">This application is provided as-is for <strong style="color:#f0ece6">demonstration and evaluation purposes only</strong>.
|
| 232 |
-
UrbanFlow provides an estimated accuracy of ±5–8% on dense mixed-traffic footage. For research use, we recommend processing the same video 2–3 times and taking the average count.</p>
|
| 233 |
-
<p style="color:#a89f97;font-size:11px;text-align:left">Outputs are not intended for operational, regulatory, or safety-critical use. This is an early-stage research project, not a commercial product.</p>
|
| 234 |
-
<p style="color:#555;font-size:10px;margin-top:16px;text-align:left">For any queries: <strong
|
| 235 |
-
style="color:#c89a6c">support.urbanflow365@gmail.com</strong></p>
|
| 236 |
-
</div>
|
| 237 |
-
</div>
|
| 238 |
|
| 239 |
<!-- Onboarding Walkthrough -->
|
| 240 |
<div id="onboard-overlay" class="onboard-overlay" style="display:none">
|
|
@@ -270,12 +217,5 @@
|
|
| 270 |
</div>
|
| 271 |
</div>
|
| 272 |
|
| 273 |
-
<script>
|
| 274 |
-
if ('serviceWorker' in navigator) {
|
| 275 |
-
window.addEventListener('load', () => {
|
| 276 |
-
navigator.serviceWorker.register('./sw.js');
|
| 277 |
-
});
|
| 278 |
-
}
|
| 279 |
-
</script>
|
| 280 |
</body>
|
| 281 |
</html>
|
|
|
|
| 23 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 24 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 25 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
| 26 |
+
<link rel="stylesheet" href="css/shared.css">
|
| 27 |
<link rel="stylesheet" href="css/initial.css">
|
| 28 |
+
<link rel="stylesheet" href="css/auth.css">
|
| 29 |
</head>
|
| 30 |
|
| 31 |
<body class="bg-black text-white min-h-screen w-full flex flex-col items-center selection:bg-white selection:text-black overflow-x-hidden">
|
| 32 |
|
| 33 |
+
|
| 34 |
<header class="mt-16 flex flex-col items-center flex-shrink-0 w-full z-10">
|
| 35 |
<img src="assets/uf_rf.png" alt="UrbanFlow Logo" class="h-44 md:h-52 w-auto object-contain mb-3">
|
| 36 |
</header>
|
|
|
|
| 47 |
</h1>
|
| 48 |
<p class="font-bold mb-8 text-sm uppercase tracking-[0.2em] flex items-center justify-center sm:justify-start" style="color:#a89f97">
|
| 49 |
<span class="core-badge px-3 py-1 rounded-full text-[10px] mr-3">Beta</span>
|
| 50 |
+
Field Intelligence Platform
|
| 51 |
</p>
|
| 52 |
+
|
| 53 |
+
<div class="hero-desc mb-10 text-center sm:text-left" style="color:#a89f97">
|
| 54 |
+
<p class="hero-desc-lead mb-4 font-semibold text-white">
|
| 55 |
+
Transform video feeds into actionable intelligence.
|
| 56 |
+
</p>
|
| 57 |
+
<p class="hero-desc-body">
|
| 58 |
+
UrbanFlow converts raw footage from any CCTV, dashcam, or drone into high-precision structured data—delivering instant vehicle counts, classifications, and flow analytics without requiring any hardware installation. Simply upload a clip, define your spatial boundaries, and receive enterprise-grade analytical reports in minutes.
|
| 59 |
+
</p>
|
| 60 |
+
</div>
|
| 61 |
|
| 62 |
<div class="mt-10 flex justify-center sm:justify-start">
|
| 63 |
+
<button onclick="openOnboarding()" class="transition-all active:scale-95 hover:text-[#c89a6c] group cursor-pointer font-bold text-white text-base tracking-[0.15em] uppercase" style="font-family: 'Montserrat', sans-serif;">
|
| 64 |
+
HOW TO USE?
|
|
|
|
|
|
|
|
|
|
| 65 |
</button>
|
| 66 |
</div>
|
| 67 |
</div>
|
|
|
|
| 73 |
<h2 class="text-3xl font-bold mb-2 text-center" style="color:#f0ece6">UrbanFlow</h2>
|
| 74 |
<p class="text-[13px] font-medium mb-8 text-center" style="color:#a89f97">Proceed to analysis</p>
|
| 75 |
<div class="flex justify-center w-full">
|
| 76 |
+
<div onclick="handleTrafficDynamicsClick()"
|
| 77 |
class="group relative border-2 rounded-[2rem] p-8 cursor-pointer hover:-translate-y-1 transition-all duration-300 text-center max-w-sm w-full traffic-dynamics-card">
|
| 78 |
<div class="absolute top-4 right-6 text-[9px] font-bold px-2.5 py-1 rounded-full uppercase tracking-wider"
|
| 79 |
style="background:#c89a6c;color:#000">BETA</div>
|
|
|
|
| 123 |
</div>
|
| 124 |
<canvas id="drawing-canvas" class="absolute inset-0 w-full h-full"></canvas>
|
| 125 |
</div>
|
| 126 |
+
<p class="text-[11px] text-center mb-6" style="color:#a89f97">
|
| 127 |
+
<em>Draw across the lane of travel, not along it.</em>
|
| 128 |
+
<!-- TODO: Add 3 correct angle visual graphics later -->
|
| 129 |
+
</p>
|
| 130 |
<div class="flex flex-col items-center gap-3">
|
| 131 |
<button id="btn-proceed" onclick="startRun()"
|
| 132 |
class="w-fit px-16 py-3.5 rounded-full font-bold transition-all text-center text-sm shadow-lg hover:scale-105 active:scale-95"
|
| 133 |
+
style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
|
| 134 |
Continue →
|
| 135 |
</button>
|
| 136 |
<button onclick="resetCanvas()"
|
| 137 |
+
class="text-[11px] font-bold uppercase tracking-widest text-slate-300 hover:text-white transition px-4 py-2 mt-2 bg-neutral-900 rounded-full border border-neutral-700"
|
| 138 |
+
style="border-color:#333;">Reset </button>
|
| 139 |
</div>
|
| 140 |
</div>
|
| 141 |
|
|
|
|
| 144 |
|
| 145 |
<footer class="w-full max-w-[90rem] mx-auto px-10 mt-auto z-10 text-[11px] font-bold uppercase tracking-[0.2em]" style="color:#777">
|
| 146 |
|
| 147 |
+
<div class="py-6 w-full">
|
| 148 |
+
<!-- Mobile: two links side-by-side, copyright below centered -->
|
| 149 |
+
<!-- Desktop: three columns left / center / right -->
|
| 150 |
+
<div class="flex flex-col md:flex-row md:justify-between md:items-center gap-2 md:gap-0">
|
| 151 |
+
<div class="flex justify-between md:block md:flex-none md:w-auto">
|
| 152 |
+
<button onclick="openAppModal('privacyModal')" class="hover:text-white transition md:mr-6">Privacy Policy</button>
|
| 153 |
+
<button onclick="openAppModal('termsModal')" class="hover:text-white transition">Terms & Conditions</button>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="text-center md:text-right">
|
| 156 |
+
© 2026 UrbanFlow. All rights reserved.
|
| 157 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
</div>
|
| 159 |
</div>
|
| 160 |
|
| 161 |
</footer>
|
| 162 |
|
| 163 |
+
<script src="js/templates.js"></script>
|
| 164 |
+
<script src="js/shared.js"></script>
|
| 165 |
+
<script src="js/auth.js"></script>
|
| 166 |
<script src="js/initial.js"></script>
|
| 167 |
<script>
|
| 168 |
+
// Auth-gated entry into Traffic Dynamics
|
| 169 |
+
function handleTrafficDynamicsClick() {
|
| 170 |
+
if (isAuthenticated()) {
|
| 171 |
+
showStep('upload');
|
| 172 |
+
} else {
|
| 173 |
+
promptGoogleSignIn(function() {
|
| 174 |
+
showStep('upload');
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
}
|
| 178 |
+
|
| 179 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 180 |
+
injectLegalModals();
|
| 181 |
});
|
| 182 |
</script>
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
<!-- Onboarding Walkthrough -->
|
| 187 |
<div id="onboard-overlay" class="onboard-overlay" style="display:none">
|
|
|
|
| 217 |
</div>
|
| 218 |
</div>
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
</body>
|
| 221 |
</html>
|
frontend/js/auth.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* UrbanFlow — auth.js
|
| 3 |
+
* Client-side Google Identity Services integration.
|
| 4 |
+
*
|
| 5 |
+
* Session is persisted in localStorage so it survives page reloads
|
| 6 |
+
* and HF Space container restarts. The backend verifies the JWT on
|
| 7 |
+
* every sign-in and returns the canonical user record.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
const UF_AUTH_KEYS = {
|
| 11 |
+
email: 'uf_user_email',
|
| 12 |
+
name: 'uf_user_name',
|
| 13 |
+
picture: 'uf_user_picture',
|
| 14 |
+
username: 'uf_user_username',
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// ---- Session helpers ----
|
| 18 |
+
|
| 19 |
+
function getAuthSession() {
|
| 20 |
+
const email = localStorage.getItem(UF_AUTH_KEYS.email);
|
| 21 |
+
if (!email) return null;
|
| 22 |
+
return {
|
| 23 |
+
email: email,
|
| 24 |
+
name: localStorage.getItem(UF_AUTH_KEYS.name) || '',
|
| 25 |
+
picture: localStorage.getItem(UF_AUTH_KEYS.picture) || '',
|
| 26 |
+
username: localStorage.getItem(UF_AUTH_KEYS.username) || '',
|
| 27 |
+
};
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function isAuthenticated() {
|
| 31 |
+
return !!localStorage.getItem(UF_AUTH_KEYS.email);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function saveAuthSession(user) {
|
| 35 |
+
localStorage.setItem(UF_AUTH_KEYS.email, user.email || '');
|
| 36 |
+
localStorage.setItem(UF_AUTH_KEYS.name, user.name || '');
|
| 37 |
+
localStorage.setItem(UF_AUTH_KEYS.picture, user.picture || '');
|
| 38 |
+
localStorage.setItem(UF_AUTH_KEYS.username, user.username || '');
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function clearAuthSession() {
|
| 42 |
+
Object.values(UF_AUTH_KEYS).forEach(k => localStorage.removeItem(k));
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ---- Google Identity Services ----
|
| 46 |
+
|
| 47 |
+
let _gsiInitialized = false;
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Load GIS script and initialize. Returns a Promise that resolves
|
| 51 |
+
* once `google.accounts.id` is ready.
|
| 52 |
+
*/
|
| 53 |
+
function initGoogleAuth() {
|
| 54 |
+
return new Promise((resolve, reject) => {
|
| 55 |
+
if (_gsiInitialized) { resolve(); return; }
|
| 56 |
+
|
| 57 |
+
fetch('api/auth/client-id')
|
| 58 |
+
.then(r => r.json())
|
| 59 |
+
.then(data => {
|
| 60 |
+
if (data.error) { reject(data.error); return; }
|
| 61 |
+
|
| 62 |
+
const script = document.createElement('script');
|
| 63 |
+
script.src = 'https://accounts.google.com/gsi/client';
|
| 64 |
+
script.async = true;
|
| 65 |
+
script.defer = true;
|
| 66 |
+
script.onload = () => {
|
| 67 |
+
google.accounts.id.initialize({
|
| 68 |
+
client_id: data.client_id,
|
| 69 |
+
callback: _handleCredentialResponse,
|
| 70 |
+
auto_select: false,
|
| 71 |
+
cancel_on_tap_outside: false,
|
| 72 |
+
});
|
| 73 |
+
_gsiInitialized = true;
|
| 74 |
+
resolve();
|
| 75 |
+
};
|
| 76 |
+
script.onerror = () => reject('Failed to load Google Identity Services');
|
| 77 |
+
document.head.appendChild(script);
|
| 78 |
+
})
|
| 79 |
+
.catch(reject);
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Click the hidden real Google button — this is the only reliable way
|
| 85 |
+
* to open the account chooser popup from a user gesture.
|
| 86 |
+
*/
|
| 87 |
+
function _triggerGoogleSignInPopup() {
|
| 88 |
+
const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
|
| 89 |
+
if (realBtn) {
|
| 90 |
+
realBtn.click();
|
| 91 |
+
} else {
|
| 92 |
+
// Fallback: re-render and click once rendered
|
| 93 |
+
_renderHiddenGoogleBtn(function() {
|
| 94 |
+
const btn = document.querySelector('#gsi-hidden-btn [role="button"]');
|
| 95 |
+
if (btn) btn.click();
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Render Google's real button into a hidden offscreen container,
|
| 102 |
+
* then call `cb` once the iframe/button is ready.
|
| 103 |
+
*/
|
| 104 |
+
function _renderHiddenGoogleBtn(cb) {
|
| 105 |
+
let container = document.getElementById('gsi-hidden-btn');
|
| 106 |
+
if (!container) {
|
| 107 |
+
container = document.createElement('div');
|
| 108 |
+
container.id = 'gsi-hidden-btn';
|
| 109 |
+
container.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;pointer-events:none;';
|
| 110 |
+
document.body.appendChild(container);
|
| 111 |
+
}
|
| 112 |
+
container.innerHTML = '';
|
| 113 |
+
google.accounts.id.renderButton(container, {
|
| 114 |
+
type: 'standard',
|
| 115 |
+
theme: 'filled_black',
|
| 116 |
+
size: 'large',
|
| 117 |
+
text: 'signin_with',
|
| 118 |
+
shape: 'pill',
|
| 119 |
+
width: 240,
|
| 120 |
+
});
|
| 121 |
+
// Give the iframe a tick to mount
|
| 122 |
+
setTimeout(function() {
|
| 123 |
+
container.style.pointerEvents = 'auto';
|
| 124 |
+
if (cb) cb();
|
| 125 |
+
}, 100);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Internal: will be overridden by the page that triggers sign-in
|
| 129 |
+
let _onAuthSuccess = null;
|
| 130 |
+
|
| 131 |
+
function _handleCredentialResponse(response) {
|
| 132 |
+
fetch('api/auth/verify', {
|
| 133 |
+
method: 'POST',
|
| 134 |
+
headers: { 'Content-Type': 'application/json' },
|
| 135 |
+
body: JSON.stringify({ credential: response.credential }),
|
| 136 |
+
})
|
| 137 |
+
.then(r => {
|
| 138 |
+
if (!r.ok) throw new Error('Verification failed');
|
| 139 |
+
return r.json();
|
| 140 |
+
})
|
| 141 |
+
.then(user => {
|
| 142 |
+
saveAuthSession(user);
|
| 143 |
+
|
| 144 |
+
const handleSuccess = (u) => {
|
| 145 |
+
_hideAuthOverlay();
|
| 146 |
+
if (_onAuthSuccess) _onAuthSuccess(u);
|
| 147 |
+
else {
|
| 148 |
+
if (typeof populateProfileUI === 'function') populateProfileUI();
|
| 149 |
+
}
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
if (user.new_user) {
|
| 153 |
+
_showOnboardingForm(user, function(u) {
|
| 154 |
+
// After onboarding, check consent
|
| 155 |
+
_checkConsentThenProceed(u, handleSuccess);
|
| 156 |
+
});
|
| 157 |
+
} else {
|
| 158 |
+
// Existing user — check consent
|
| 159 |
+
_checkConsentThenProceed(user, handleSuccess);
|
| 160 |
+
}
|
| 161 |
+
})
|
| 162 |
+
.catch(err => {
|
| 163 |
+
console.error('[AUTH]', err);
|
| 164 |
+
const overlay = document.getElementById('auth-overlay');
|
| 165 |
+
const errEl = overlay ? overlay.querySelector('.auth-error') : null;
|
| 166 |
+
if (errEl) {
|
| 167 |
+
errEl.textContent = 'Sign-in failed. Please try again.';
|
| 168 |
+
errEl.classList.remove('hidden');
|
| 169 |
+
}
|
| 170 |
+
});
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Show the auth overlay, render the custom button that delegates
|
| 175 |
+
* to the real Google button click for a reliable account chooser.
|
| 176 |
+
*/
|
| 177 |
+
function promptGoogleSignIn(onSuccess) {
|
| 178 |
+
_onAuthSuccess = (user) => {
|
| 179 |
+
if (user.new_user) {
|
| 180 |
+
_showOnboardingForm(user, onSuccess);
|
| 181 |
+
} else {
|
| 182 |
+
_hideAuthOverlay();
|
| 183 |
+
onSuccess(user);
|
| 184 |
+
}
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
_showAuthOverlay();
|
| 188 |
+
|
| 189 |
+
initGoogleAuth().then(() => {
|
| 190 |
+
const btnContainer = document.getElementById('auth-google-btn');
|
| 191 |
+
if (!btnContainer) return;
|
| 192 |
+
|
| 193 |
+
// Render the hidden real Google button first so it's ready
|
| 194 |
+
_renderHiddenGoogleBtn(null);
|
| 195 |
+
|
| 196 |
+
// Show our styled button that delegates to it
|
| 197 |
+
btnContainer.innerHTML = TEMPLATES.authGoogleBtn;
|
| 198 |
+
});
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// ---- Auth Overlay (injected into DOM) ----
|
| 202 |
+
|
| 203 |
+
function _showAuthOverlay() {
|
| 204 |
+
let overlay = document.getElementById('auth-overlay');
|
| 205 |
+
if (!overlay) {
|
| 206 |
+
overlay = document.createElement('div');
|
| 207 |
+
overlay.id = 'auth-overlay';
|
| 208 |
+
overlay.className = 'auth-overlay';
|
| 209 |
+
overlay.innerHTML = TEMPLATES.authOverlay;
|
| 210 |
+
document.body.appendChild(overlay);
|
| 211 |
+
overlay.addEventListener('click', function(e) {
|
| 212 |
+
if (e.target === overlay) _hideAuthOverlay();
|
| 213 |
+
});
|
| 214 |
+
}
|
| 215 |
+
overlay.style.display = 'flex';
|
| 216 |
+
document.body.style.overflow = 'hidden';
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function _hideAuthOverlay() {
|
| 220 |
+
const overlay = document.getElementById('auth-overlay');
|
| 221 |
+
if (overlay) {
|
| 222 |
+
overlay.style.display = 'none';
|
| 223 |
+
document.body.style.overflow = '';
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// ---- Onboarding Form (username) ----
|
| 228 |
+
|
| 229 |
+
function _showOnboardingForm(user, onSuccess) {
|
| 230 |
+
const overlay = document.getElementById('auth-overlay');
|
| 231 |
+
if (!overlay) return;
|
| 232 |
+
|
| 233 |
+
const card = overlay.querySelector('.auth-card');
|
| 234 |
+
card.innerHTML = getOnboardFormTemplate(user);
|
| 235 |
+
overlay._onboardUser = user;
|
| 236 |
+
overlay._onboardCallback = onSuccess;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function _submitOnboarding() {
|
| 240 |
+
const overlay = document.getElementById('auth-overlay');
|
| 241 |
+
const input = document.getElementById('auth-username-input');
|
| 242 |
+
const errEl = document.getElementById('auth-onboard-error');
|
| 243 |
+
const username = (input ? input.value : '').trim();
|
| 244 |
+
|
| 245 |
+
if (!username || username.length < 2) {
|
| 246 |
+
if (errEl) { errEl.textContent = 'Please enter at least 2 characters.'; errEl.classList.remove('hidden'); }
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const user = overlay._onboardUser;
|
| 251 |
+
const btn = document.getElementById('auth-onboard-submit');
|
| 252 |
+
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
|
| 253 |
+
|
| 254 |
+
fetch('api/auth/onboard', {
|
| 255 |
+
method: 'POST',
|
| 256 |
+
headers: { 'Content-Type': 'application/json' },
|
| 257 |
+
body: JSON.stringify({ email: user.email, username: username }),
|
| 258 |
+
})
|
| 259 |
+
.then(r => {
|
| 260 |
+
if (!r.ok) throw new Error('Onboarding failed');
|
| 261 |
+
return r.json();
|
| 262 |
+
})
|
| 263 |
+
.then(() => {
|
| 264 |
+
user.username = username;
|
| 265 |
+
saveAuthSession(user);
|
| 266 |
+
_hideAuthOverlay();
|
| 267 |
+
if (overlay._onboardCallback) overlay._onboardCallback(user);
|
| 268 |
+
})
|
| 269 |
+
.catch(() => {
|
| 270 |
+
if (errEl) { errEl.textContent = 'Something went wrong. Please try again.'; errEl.classList.remove('hidden'); }
|
| 271 |
+
if (btn) { btn.disabled = false; btn.textContent = 'Continue'; }
|
| 272 |
+
});
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// ---- Logout ----
|
| 276 |
+
|
| 277 |
+
function showLogoutConfirm() {
|
| 278 |
+
let modal = document.getElementById('logout-confirm-modal');
|
| 279 |
+
if (!modal) {
|
| 280 |
+
modal = document.createElement('div');
|
| 281 |
+
modal.id = 'logout-confirm-modal';
|
| 282 |
+
modal.className = 'auth-overlay';
|
| 283 |
+
modal.onclick = (e) => { if (e.target === modal) hideLogoutConfirm(); };
|
| 284 |
+
modal.innerHTML = TEMPLATES.logoutModal;
|
| 285 |
+
document.body.appendChild(modal);
|
| 286 |
+
}
|
| 287 |
+
modal.style.display = 'flex';
|
| 288 |
+
document.body.style.overflow = 'hidden';
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function hideLogoutConfirm() {
|
| 292 |
+
const modal = document.getElementById('logout-confirm-modal');
|
| 293 |
+
if (modal) { modal.style.display = 'none'; document.body.style.overflow = ''; }
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function executeLogout() {
|
| 297 |
+
clearAuthSession();
|
| 298 |
+
sessionStorage.clear();
|
| 299 |
+
hideLogoutConfirm();
|
| 300 |
+
if (typeof showOnboardingPhase === 'function') {
|
| 301 |
+
showOnboardingPhase();
|
| 302 |
+
if (typeof initApp === 'function') {
|
| 303 |
+
const sp = document.getElementById('sidebar-profile');
|
| 304 |
+
if (sp) sp.style.display = 'none';
|
| 305 |
+
}
|
| 306 |
+
} else {
|
| 307 |
+
window.location.replace('/');
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// ---- Consent Modal ----
|
| 312 |
+
|
| 313 |
+
let _consentCallback = null;
|
| 314 |
+
let _consentUser = null;
|
| 315 |
+
|
| 316 |
+
function _checkConsentThenProceed(user, callback) {
|
| 317 |
+
if (localStorage.getItem('uf_terms_accepted')) {
|
| 318 |
+
callback(user);
|
| 319 |
+
return;
|
| 320 |
+
}
|
| 321 |
+
// Show consent modal
|
| 322 |
+
_consentCallback = callback;
|
| 323 |
+
_consentUser = user;
|
| 324 |
+
_showConsentModal();
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function _showConsentModal() {
|
| 328 |
+
let overlay = document.getElementById('consent-overlay');
|
| 329 |
+
if (!overlay) {
|
| 330 |
+
overlay = document.createElement('div');
|
| 331 |
+
overlay.id = 'consent-overlay';
|
| 332 |
+
overlay.className = 'auth-overlay';
|
| 333 |
+
overlay.innerHTML = TEMPLATES.consentModal;
|
| 334 |
+
document.body.appendChild(overlay);
|
| 335 |
+
// No click-to-close — user must agree
|
| 336 |
+
}
|
| 337 |
+
overlay.style.display = 'flex';
|
| 338 |
+
document.body.style.overflow = 'hidden';
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
function onConsentCheckboxChange() {
|
| 342 |
+
const cb = document.getElementById('consent-checkbox');
|
| 343 |
+
const btn = document.getElementById('consent-accept-btn');
|
| 344 |
+
const label = document.getElementById('consent-label');
|
| 345 |
+
if (!cb || !btn) return;
|
| 346 |
+
if (cb.checked) {
|
| 347 |
+
btn.disabled = false;
|
| 348 |
+
btn.style.background = '#0a0a0a';
|
| 349 |
+
btn.style.border = '1px solid var(--cocoa)';
|
| 350 |
+
btn.style.color = 'var(--cocoa-l)';
|
| 351 |
+
btn.style.cursor = 'pointer';
|
| 352 |
+
if (label) label.style.borderColor = 'var(--cocoa)';
|
| 353 |
+
} else {
|
| 354 |
+
btn.disabled = true;
|
| 355 |
+
btn.style.background = '#1a1a1a';
|
| 356 |
+
btn.style.border = '1px solid #222';
|
| 357 |
+
btn.style.color = '#555';
|
| 358 |
+
btn.style.cursor = 'not-allowed';
|
| 359 |
+
if (label) label.style.borderColor = '#222';
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function acceptConsent() {
|
| 364 |
+
localStorage.setItem('uf_terms_accepted', 'true');
|
| 365 |
+
const overlay = document.getElementById('consent-overlay');
|
| 366 |
+
if (overlay) {
|
| 367 |
+
overlay.style.display = 'none';
|
| 368 |
+
document.body.style.overflow = '';
|
| 369 |
+
}
|
| 370 |
+
if (_consentCallback && _consentUser) {
|
| 371 |
+
_consentCallback(_consentUser);
|
| 372 |
+
}
|
| 373 |
+
_consentCallback = null;
|
| 374 |
+
_consentUser = null;
|
| 375 |
+
}
|
frontend/js/initial.js
CHANGED
|
@@ -71,7 +71,7 @@ function uploadFile(file) {
|
|
| 71 |
// Estimate upload duration: ~1 MB/s conservative, capped between 3s and 60s
|
| 72 |
const fileMB = file.size / (1024 * 1024);
|
| 73 |
const estDurationMs = Math.min(Math.max(fileMB * 1000, 3000), 60000);
|
| 74 |
-
const targetPct =
|
| 75 |
const tickMs = 200; // update every 200ms
|
| 76 |
const totalTicks = estDurationMs / tickMs;
|
| 77 |
const stepPerTick = targetPct / totalTicks;
|
|
@@ -87,6 +87,12 @@ function uploadFile(file) {
|
|
| 87 |
|
| 88 |
const form = new FormData();
|
| 89 |
form.append('file', file);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
const xhr = new XMLHttpRequest();
|
| 92 |
currentXHR = xhr;
|
|
@@ -122,9 +128,9 @@ function uploadFile(file) {
|
|
| 122 |
return;
|
| 123 |
}
|
| 124 |
|
| 125 |
-
// Snap to
|
| 126 |
-
bar.style.width = '
|
| 127 |
-
pct.innerText = '
|
| 128 |
|
| 129 |
const res = JSON.parse(xhr.responseText);
|
| 130 |
videoId = res.video_id;
|
|
@@ -133,10 +139,19 @@ function uploadFile(file) {
|
|
| 133 |
fetch('config/' + videoId)
|
| 134 |
.then(r => r.json())
|
| 135 |
.then(cfg => {
|
|
|
|
|
|
|
| 136 |
runConfig = cfg;
|
| 137 |
runConfig.conf = 0.12;
|
| 138 |
runConfig.iou = 0.60;
|
| 139 |
txt.innerText = 'Initialization Complete';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
if (fileInput) fileInput.value = '';
|
| 141 |
setTimeout(() => showStep('draw'), 800);
|
| 142 |
})
|
|
@@ -278,9 +293,20 @@ function startRun() {
|
|
| 278 |
config: runConfig
|
| 279 |
}));
|
| 280 |
sessionStorage.setItem('uf_active_tab', 'settings');
|
| 281 |
-
|
| 282 |
-
//
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
}
|
| 285 |
|
| 286 |
// =============================================
|
|
|
|
| 71 |
// Estimate upload duration: ~1 MB/s conservative, capped between 3s and 60s
|
| 72 |
const fileMB = file.size / (1024 * 1024);
|
| 73 |
const estDurationMs = Math.min(Math.max(fileMB * 1000, 3000), 60000);
|
| 74 |
+
const targetPct = 98; // stop simulation at 98%, snap to 100% on load
|
| 75 |
const tickMs = 200; // update every 200ms
|
| 76 |
const totalTicks = estDurationMs / tickMs;
|
| 77 |
const stepPerTick = targetPct / totalTicks;
|
|
|
|
| 87 |
|
| 88 |
const form = new FormData();
|
| 89 |
form.append('file', file);
|
| 90 |
+
|
| 91 |
+
fetch('/api/event', {
|
| 92 |
+
method: 'POST',
|
| 93 |
+
headers: { 'Content-Type': 'application/json' },
|
| 94 |
+
body: JSON.stringify({ event: 'UPLOAD_STARTED', meta: { size: fileMB.toFixed(2) } })
|
| 95 |
+
}).catch(()=>{});
|
| 96 |
|
| 97 |
const xhr = new XMLHttpRequest();
|
| 98 |
currentXHR = xhr;
|
|
|
|
| 128 |
return;
|
| 129 |
}
|
| 130 |
|
| 131 |
+
// Snap to 95% on successful upload response
|
| 132 |
+
bar.style.width = '95%';
|
| 133 |
+
pct.innerText = '95%';
|
| 134 |
|
| 135 |
const res = JSON.parse(xhr.responseText);
|
| 136 |
videoId = res.video_id;
|
|
|
|
| 139 |
fetch('config/' + videoId)
|
| 140 |
.then(r => r.json())
|
| 141 |
.then(cfg => {
|
| 142 |
+
bar.style.width = '100%';
|
| 143 |
+
pct.innerText = '100%';
|
| 144 |
runConfig = cfg;
|
| 145 |
runConfig.conf = 0.12;
|
| 146 |
runConfig.iou = 0.60;
|
| 147 |
txt.innerText = 'Initialization Complete';
|
| 148 |
+
|
| 149 |
+
fetch('/api/event', {
|
| 150 |
+
method: 'POST',
|
| 151 |
+
headers: { 'Content-Type': 'application/json' },
|
| 152 |
+
body: JSON.stringify({ event: 'UPLOAD_SUCCESS', meta: { video_id: videoId } })
|
| 153 |
+
}).catch(()=>{});
|
| 154 |
+
|
| 155 |
if (fileInput) fileInput.value = '';
|
| 156 |
setTimeout(() => showStep('draw'), 800);
|
| 157 |
})
|
|
|
|
| 293 |
config: runConfig
|
| 294 |
}));
|
| 295 |
sessionStorage.setItem('uf_active_tab', 'settings');
|
| 296 |
+
|
| 297 |
+
fetch('/api/event', {
|
| 298 |
+
method: 'POST',
|
| 299 |
+
headers: { 'Content-Type': 'application/json' },
|
| 300 |
+
body: JSON.stringify({ event: 'DRAW_LINE_COMPLETED', meta: { video_id: videoId } })
|
| 301 |
+
}).catch(()=>{});
|
| 302 |
+
|
| 303 |
+
// SPA Navigation
|
| 304 |
+
if (typeof showDashboard === 'function') {
|
| 305 |
+
showDashboard();
|
| 306 |
+
if (typeof initApp === 'function') initApp();
|
| 307 |
+
} else {
|
| 308 |
+
window.location.replace('/vehicles');
|
| 309 |
+
}
|
| 310 |
}
|
| 311 |
|
| 312 |
// =============================================
|
frontend/js/shared.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* UrbanFlow — shared.js
|
| 3 |
+
* Shared utilities, modal management, and legal content injection.
|
| 4 |
+
* Loaded on every page before page-specific scripts.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
// =============================================
|
| 8 |
+
// Modal Management
|
| 9 |
+
// =============================================
|
| 10 |
+
|
| 11 |
+
function openAppModal(id) {
|
| 12 |
+
const el = document.getElementById('appModal-' + id);
|
| 13 |
+
if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function closeAppModal(id) {
|
| 17 |
+
const el = document.getElementById('appModal-' + id);
|
| 18 |
+
if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// =============================================
|
| 22 |
+
// Legal Modals — Single Source of Truth
|
| 23 |
+
// =============================================
|
| 24 |
+
|
| 25 |
+
const LEGAL_CONTENT = {
|
| 26 |
+
privacy: {
|
| 27 |
+
title: 'Privacy Policy',
|
| 28 |
+
subtitle: 'Last Updated: 28th April 2026',
|
| 29 |
+
content: `
|
| 30 |
+
<p><strong>1. WHO WE ARE</strong><br>
|
| 31 |
+
UrbanFlow is a computer vision software that analyses traffic video footage to produce structured traffic data. It is hosted on Hugging Face Spaces as a demonstration and research product by Perception365.<br>
|
| 32 |
+
Contact: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong></p>
|
| 33 |
+
|
| 34 |
+
<p><strong>2. WHAT DATA WE COLLECT</strong><br>
|
| 35 |
+
<strong>a) Account Data:</strong> When you sign in with Google, we receive your name, email address, and profile picture. We store only what is necessary to identify your session.<br>
|
| 36 |
+
<strong>b) Uploaded Video Footage:</strong> Videos you upload are processed temporarily for inference only. We do not store your original footage. All uploaded files are deleted automatically once processing is complete and session cache is cleared each run.<br>
|
| 37 |
+
<strong>c) Analysis Outputs:</strong> Processed results are tied to your active session only. We do not sell or share this data with anyone.<br>
|
| 38 |
+
<strong>d) Usage Logs:</strong> We collect standard server logs (IP address, browser type, timestamps) for security monitoring and platform improvement only.</p>
|
| 39 |
+
|
| 40 |
+
<p><strong>3. HOW WE USE YOUR DATA</strong><br>
|
| 41 |
+
We use your data solely to:<br>
|
| 42 |
+
- Authenticate your identity and maintain your session<br>
|
| 43 |
+
- Process uploaded footage and return results to you<br>
|
| 44 |
+
- Detect and prevent misuse of the platform<br>
|
| 45 |
+
- Comply with legal obligations<br>
|
| 46 |
+
We do not use your data for advertising. We do not sell your data to any third party under any circumstances.</p>
|
| 47 |
+
|
| 48 |
+
<p><strong>4. DATA SHARING</strong><br>
|
| 49 |
+
We do not share your personal data with third parties except:<br>
|
| 50 |
+
- Google — for authentication (governed by Google's Privacy Policy)<br>
|
| 51 |
+
- Hugging Face — infrastructure hosting (see huggingface.co/privacy)<br>
|
| 52 |
+
- Legal authorities — only if required by law<br>
|
| 53 |
+
We have no control over Hugging Face's infrastructure-level data handling.</p>
|
| 54 |
+
|
| 55 |
+
<p><strong>5. DATA SECURITY</strong><br>
|
| 56 |
+
We apply reasonable technical measures to protect data in transit and at rest. However, no system is perfectly secure. We operate on shared cloud infrastructure and cannot guarantee absolute security. By using UrbanFlow, you acknowledge and accept this risk.<br>
|
| 57 |
+
You should not upload footage containing sensitive personal information, private property interiors, classified content, or any material you are not authorised to share.</p>
|
| 58 |
+
|
| 59 |
+
<p><strong>6. YOUR RIGHTS</strong><br>
|
| 60 |
+
Since UrbanFlow does not maintain permanent user accounts, signing out clears your session and associated data entirely. You may also contact us to ask what data is held or to report a concern.<br>
|
| 61 |
+
Contact: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong><br>
|
| 62 |
+
We will respond within 30 days.</p>
|
| 63 |
+
|
| 64 |
+
<p><strong>7. CHANGES TO THIS POLICY</strong><br>
|
| 65 |
+
We may update this policy as the platform evolves. The "Last Updated" date at the top reflects the most recent revision. Continued use after changes constitutes acceptance of the updated policy.</p>
|
| 66 |
+
`
|
| 67 |
+
},
|
| 68 |
+
terms: {
|
| 69 |
+
title: 'Terms & Conditions',
|
| 70 |
+
subtitle: 'Last Updated: 28th April 2026',
|
| 71 |
+
content: `
|
| 72 |
+
<p><strong>1. ACCEPTANCE</strong><br>
|
| 73 |
+
By accessing or using UrbanFlow, you confirm that you have read, understood, and agree to these Terms and Conditions in full. If you do not agree, do not use the platform.<br>
|
| 74 |
+
We are not responsible for any risks arising from future use of the platform beyond what is expressly stated here.</p>
|
| 75 |
+
|
| 76 |
+
<p><strong>2. NATURE OF THE PLATFORM</strong><br>
|
| 77 |
+
UrbanFlow is a beta product provided for research, evaluation, and non-critical use only. It is not intended for use in life-safety systems, emergency response, legal proceedings, or any context where errors in traffic data could cause harm.<br>
|
| 78 |
+
The platform may be modified, suspended, or discontinued at any time without prior notice.</p>
|
| 79 |
+
|
| 80 |
+
<p><strong>3. ELIGIBILITY</strong><br>
|
| 81 |
+
You must be at least 18 years old and hold a valid Google account to use UrbanFlow. By signing in, you confirm you meet these requirements.</p>
|
| 82 |
+
|
| 83 |
+
<p><strong>4. ACCEPTABLE USE</strong><br>
|
| 84 |
+
You agree to use UrbanFlow only for its intended purpose: analysing road or traffic video footage to obtain traffic flow data.<br>
|
| 85 |
+
You must NOT:<br>
|
| 86 |
+
a) Upload footage unrelated to traffic or road monitoring — including personal recordings, indoor footage, private events, or surveillance of individuals.<br>
|
| 87 |
+
b) Upload footage you do not have the legal right to use or process.<br>
|
| 88 |
+
c) Upload footage containing nudity, violence, illegal activity, or any content that violates applicable law.<br>
|
| 89 |
+
d) Attempt to reverse-engineer, scrape, or abuse the platform or its API.<br>
|
| 90 |
+
e) Use the platform to identify, track, or surveil specific individuals without their explicit consent.<br>
|
| 91 |
+
f) Upload classified, confidential, or government-restricted materials.<br>
|
| 92 |
+
g) Use bots or automated scripts to interact with the platform.<br>
|
| 93 |
+
Violation of these terms may result in immediate access termination and, where applicable, reporting to relevant authorities.</p>
|
| 94 |
+
|
| 95 |
+
<p><strong>5. YOUR RESPONSIBILITY FOR UPLOADED CONTENT</strong><br>
|
| 96 |
+
You are solely responsible for any footage you upload. By uploading, you confirm that:<br>
|
| 97 |
+
- You own the footage or hold explicit authorisation to process it<br>
|
| 98 |
+
- The footage is relevant to traffic or road monitoring<br>
|
| 99 |
+
- You are not violating any third party's rights, including privacy rights<br>
|
| 100 |
+
- The footage does not contain content prohibited under Section 4<br>
|
| 101 |
+
We do not review uploaded footage before processing and are not responsible for its content.</p>
|
| 102 |
+
|
| 103 |
+
<p><strong>6. LIMITATION OF LIABILITY</strong><br>
|
| 104 |
+
To the fullest extent permitted by applicable law:<br>
|
| 105 |
+
a) UrbanFlow is not liable for any direct, indirect, incidental, or consequential damages arising from your use of the platform.<br>
|
| 106 |
+
b) Analysis results are provided as-is. They should not be used as the sole basis for engineering, legal, or policy decisions without independent verification.<br>
|
| 107 |
+
c) In the event of a data breach, system failure, or security incident, our liability is limited to the maximum extent permitted by law. We will make reasonable efforts to notify affected users but are not liable for resulting harm.<br>
|
| 108 |
+
d) We are not responsible for platform availability or uptime, as it operates on Hugging Face's third-party infrastructure.</p>
|
| 109 |
+
|
| 110 |
+
<p><strong>7. DATA BREACH NOTICE</strong><br>
|
| 111 |
+
If a breach affecting user data occurs, we will notify affected users via their registered email address within a reasonable timeframe of becoming aware of the incident. Given that we collect only session-level identifiers and no permanent video data, the risk of significant harm from a breach is inherently limited.</p>
|
| 112 |
+
|
| 113 |
+
<p><strong>8. INTELLECTUAL PROPERTY</strong><br>
|
| 114 |
+
UrbanFlow, its models, interface, design, and associated technology are the intellectual property of Perception365. You may not copy, reproduce, or redistribute any part of the platform without written permission.<br>
|
| 115 |
+
You retain ownership of all footage you upload. By uploading, you grant us a limited, temporary licence to process that footage for analysis. This licence expires once processing is complete and the footage is deleted.</p>
|
| 116 |
+
|
| 117 |
+
<p><strong>9. THIRD-PARTY SERVICES</strong><br>
|
| 118 |
+
UrbanFlow uses:<br>
|
| 119 |
+
- Google Identity Services (authentication)<br>
|
| 120 |
+
- Hugging Face Spaces (hosting and compute)<br>
|
| 121 |
+
Use of these services is governed by their own terms and privacy policies. We are not responsible for the practices of these providers.</p>
|
| 122 |
+
|
| 123 |
+
<p><strong>10. TERMINATION</strong><br>
|
| 124 |
+
We reserve the right to suspend or terminate access to UrbanFlow at any time, with or without notice, for violations of these Terms or misuse of the platform.</p>
|
| 125 |
+
|
| 126 |
+
<p><strong>11. GOVERNING LAW</strong><br>
|
| 127 |
+
These Terms are governed by the laws of India. Any disputes shall be subject to the exclusive jurisdiction of the courts of Indore, Madhya Pradesh, India.</p>
|
| 128 |
+
|
| 129 |
+
<p><strong>12. CHANGES TO THESE TERMS</strong><br>
|
| 130 |
+
We may revise these Terms at any time. Continued use of the platform after changes are posted constitutes acceptance of the revised Terms.</p>
|
| 131 |
+
|
| 132 |
+
<p><strong>CONTACT</strong><br>
|
| 133 |
+
For any questions, concerns, or to report misuse:<br>
|
| 134 |
+
Email: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong></p>
|
| 135 |
+
`
|
| 136 |
+
},
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
function injectLegalModals() {
|
| 140 |
+
const container = document.createElement('div');
|
| 141 |
+
container.id = 'legal-modals-container';
|
| 142 |
+
|
| 143 |
+
// Privacy Modal
|
| 144 |
+
const p = LEGAL_CONTENT.privacy;
|
| 145 |
+
const privacyHTML = getPrivacyModalTemplate(p);
|
| 146 |
+
|
| 147 |
+
// Terms Modal
|
| 148 |
+
const t = LEGAL_CONTENT.terms;
|
| 149 |
+
const termsHTML = getTermsModalTemplate(t);
|
| 150 |
+
|
| 151 |
+
container.innerHTML = privacyHTML + termsHTML;
|
| 152 |
+
document.body.appendChild(container);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// =============================================
|
| 156 |
+
// Keyboard Shortcuts Modal (vehicles.html only)
|
| 157 |
+
// =============================================
|
| 158 |
+
|
| 159 |
+
const SHORTCUTS = [
|
| 160 |
+
{ label: 'About', key: '1' },
|
| 161 |
+
{ label: 'Overview', key: '2' },
|
| 162 |
+
{ label: 'Results', key: '3' },
|
| 163 |
+
{ label: 'Settings', key: '4' },
|
| 164 |
+
{ label: 'Guide', key: '5' },
|
| 165 |
+
{ label: 'Feedback', key: '6' },
|
| 166 |
+
{ label: 'Profile', key: '7' },
|
| 167 |
+
{ label: 'Download Artifacts', key: 'D' },
|
| 168 |
+
];
|
| 169 |
+
|
| 170 |
+
function injectShortcutsModal() {
|
| 171 |
+
const rows = SHORTCUTS.map(s =>
|
| 172 |
+
`<div class="shortcut-row">
|
| 173 |
+
<span class="shortcut-label">${s.label}</span>
|
| 174 |
+
<kbd class="kbd-key">${s.key}</kbd>
|
| 175 |
+
</div>`
|
| 176 |
+
).join('');
|
| 177 |
+
|
| 178 |
+
const html = getShortcutsModalTemplate(rows);
|
| 179 |
+
|
| 180 |
+
const container = document.createElement('div');
|
| 181 |
+
container.innerHTML = html;
|
| 182 |
+
document.body.appendChild(container.firstElementChild);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// =============================================
|
| 186 |
+
// Mobile Legal Menu Toggle
|
| 187 |
+
// =============================================
|
| 188 |
+
|
| 189 |
+
function toggleLegalMenu(e) {
|
| 190 |
+
if (e) e.stopPropagation();
|
| 191 |
+
const menu = document.getElementById('legal-menu');
|
| 192 |
+
if (menu) menu.classList.toggle('hidden');
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
document.addEventListener('click', function() {
|
| 196 |
+
const menu = document.getElementById('legal-menu');
|
| 197 |
+
if (menu) menu.classList.add('hidden');
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
// =============================================
|
| 201 |
+
// Global Key Handler
|
| 202 |
+
// =============================================
|
| 203 |
+
|
| 204 |
+
document.addEventListener('keydown', function(e) {
|
| 205 |
+
if (e.key === 'Escape') {
|
| 206 |
+
closeAppModal('privacyModal');
|
| 207 |
+
closeAppModal('termsModal');
|
| 208 |
+
closeAppModal('shortcutsModal');
|
| 209 |
+
if (typeof hideLogoutConfirm === 'function') hideLogoutConfirm();
|
| 210 |
+
if (typeof closeLandingProfileMenu === 'function') closeLandingProfileMenu();
|
| 211 |
+
const legalMenu = document.getElementById('legal-menu');
|
| 212 |
+
if (legalMenu) legalMenu.classList.add('hidden');
|
| 213 |
+
}
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
// =============================================
|
| 217 |
+
// Auto-inject legal modals on DOMContentLoaded
|
| 218 |
+
// =============================================
|
| 219 |
+
|
| 220 |
+
document.addEventListener('DOMContentLoaded', injectLegalModals);
|
| 221 |
+
|
| 222 |
+
// =============================================
|
| 223 |
+
// Navigation — Single Source of Truth
|
| 224 |
+
// =============================================
|
| 225 |
+
|
| 226 |
+
const NAV_ITEMS = [
|
| 227 |
+
{ id: 'about', icon: 'fa-circle-info', label: 'About' },
|
| 228 |
+
{ id: 'overview', icon: 'fa-desktop', label: 'Overview' },
|
| 229 |
+
{ id: 'results', icon: 'fa-file-lines', label: 'Results' },
|
| 230 |
+
{ id: 'settings', icon: 'fa-gear', label: 'Settings' },
|
| 231 |
+
{ id: 'help', icon: 'fa-circle-question', label: 'Guide' },
|
| 232 |
+
{ id: 'feedback', icon: 'fa-comment-dots', label: 'Feedback' },
|
| 233 |
+
{ id: 'profile', icon: 'fa-circle-user', label: 'Profile' },
|
| 234 |
+
];
|
| 235 |
+
|
| 236 |
+
function injectNavigation() {
|
| 237 |
+
// Sidebar nav (desktop)
|
| 238 |
+
const sidebarNav = document.getElementById('sidebar-nav');
|
| 239 |
+
if (sidebarNav) {
|
| 240 |
+
sidebarNav.innerHTML = NAV_ITEMS.map(n =>
|
| 241 |
+
`<a onclick="switchTab('${n.id}')" id="nav-${n.id}" class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 242 |
+
<i class="fa-solid ${n.icon} w-6"></i> <span class="font-medium">${n.label}</span>
|
| 243 |
+
</a>`
|
| 244 |
+
).join('');
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Mobile bottom nav
|
| 248 |
+
const bottomNav = document.getElementById('mobile-bottom-nav');
|
| 249 |
+
if (bottomNav) {
|
| 250 |
+
bottomNav.innerHTML = NAV_ITEMS.map(n =>
|
| 251 |
+
`<button class="mob-nav-item" id="mob-nav-${n.id}" onclick="switchTab('${n.id}')">
|
| 252 |
+
<i class="fa-solid ${n.icon}"></i>
|
| 253 |
+
</button>`
|
| 254 |
+
).join('');
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// =============================================
|
| 259 |
+
// Service Worker Registration
|
| 260 |
+
// =============================================
|
| 261 |
+
|
| 262 |
+
if ('serviceWorker' in navigator) {
|
| 263 |
+
window.addEventListener('load', function() {
|
| 264 |
+
navigator.serviceWorker.register('./sw.js');
|
| 265 |
+
});
|
| 266 |
+
}
|
frontend/js/templates.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* UrbanFlow — templates.js
|
| 3 |
+
* Stores large HTML string templates extracted from shared.js and auth.js
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const TEMPLATES = {
|
| 7 |
+
authOverlay: `
|
| 8 |
+
<div class="auth-card">
|
| 9 |
+
<div class="auth-card-header">
|
| 10 |
+
<h2 class="auth-title">Sign In</h2>
|
| 11 |
+
<p class="auth-subtitle">Authenticate with your Google account to continue</p>
|
| 12 |
+
</div>
|
| 13 |
+
<div id="auth-google-btn" class="auth-google-btn"></div>
|
| 14 |
+
<p class="auth-error hidden"></p>
|
| 15 |
+
<p class="auth-footer">Your data is handled per our <button onclick="openAppModal('privacyModal')" class="auth-link">Privacy Policy</button></p>
|
| 16 |
+
</div>
|
| 17 |
+
`,
|
| 18 |
+
|
| 19 |
+
authGoogleBtn: `
|
| 20 |
+
<button class="gsi-custom-btn" onclick="_triggerGoogleSignInPopup()">
|
| 21 |
+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
| 22 |
+
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
|
| 23 |
+
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
|
| 24 |
+
<path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
|
| 25 |
+
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 6.29C4.672 4.163 6.656 3.58 9 3.58z"/>
|
| 26 |
+
</svg>
|
| 27 |
+
<span>Continue with Google</span>
|
| 28 |
+
</button>
|
| 29 |
+
`,
|
| 30 |
+
|
| 31 |
+
logoutModal: `
|
| 32 |
+
<div class="auth-card" style="max-width:340px">
|
| 33 |
+
<h2 class="auth-title" style="margin-bottom:8px">Sign Out</h2>
|
| 34 |
+
<p class="auth-subtitle" style="margin-bottom:24px">Are you sure you want to sign out of UrbanFlow?</p>
|
| 35 |
+
<div style="display:flex;gap:12px">
|
| 36 |
+
<button onclick="hideLogoutConfirm()" class="auth-cancel-btn" style="flex:1;text-align:center;padding:12px 0">Cancel</button>
|
| 37 |
+
<button onclick="executeLogout()" style="flex:1;padding:12px 0;background:rgba(220,38,38,0.15);color:#ef4444;border:1px solid rgba(220,38,38,0.4);border-radius:10px;font-family:'Montserrat',sans-serif;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1px;cursor:pointer;transition:background 0.2s" onmouseover="this.style.background='rgba(220,38,38,0.3)'" onmouseout="this.style.background='rgba(220,38,38,0.15)'">Yes</button>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
`,
|
| 41 |
+
|
| 42 |
+
consentModal: `
|
| 43 |
+
<div class="auth-card" style="max-width:400px">
|
| 44 |
+
<div style="text-align:center;margin-bottom:20px">
|
| 45 |
+
<i class="fa-solid fa-shield-halved" style="font-size:28px;color:var(--cocoa-l);margin-bottom:12px;display:block"></i>
|
| 46 |
+
<h2 class="auth-title" style="margin-bottom:6px">Before you continue</h2>
|
| 47 |
+
<p class="auth-subtitle" style="margin-bottom:0">Please review and accept our policies to use UrbanFlow.</p>
|
| 48 |
+
</div>
|
| 49 |
+
<label id="consent-label" style="display:flex;align-items:flex-start;gap:12px;padding:16px;background:#111;border:1px solid #222;border-radius:12px;cursor:pointer;margin-bottom:20px;transition:border-color 0.2s">
|
| 50 |
+
<input type="checkbox" id="consent-checkbox" onchange="onConsentCheckboxChange()" style="margin-top:3px;accent-color:var(--cocoa-l);width:18px;height:18px;flex-shrink:0;cursor:pointer">
|
| 51 |
+
<span style="font-size:12px;color:#a89f97;line-height:1.6">
|
| 52 |
+
I have read and agree to the
|
| 53 |
+
<button onclick="event.preventDefault();openAppModal('privacyModal')" style="color:var(--cocoa-l);background:none;border:none;cursor:pointer;font-family:inherit;font-size:inherit;font-weight:700;text-decoration:underline;padding:0">Privacy Policy</button>
|
| 54 |
+
and
|
| 55 |
+
<button onclick="event.preventDefault();openAppModal('termsModal')" style="color:var(--cocoa-l);background:none;border:none;cursor:pointer;font-family:inherit;font-size:inherit;font-weight:700;text-decoration:underline;padding:0">Terms & Conditions</button>.
|
| 56 |
+
</span>
|
| 57 |
+
</label>
|
| 58 |
+
<button id="consent-accept-btn" onclick="acceptConsent()" disabled
|
| 59 |
+
style="width:100%;padding:14px 0;background:#1a1a1a;color:#555;border:1px solid #222;border-radius:12px;font-family:'Montserrat',sans-serif;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1.5px;cursor:not-allowed;transition:all 0.3s">
|
| 60 |
+
Agree & Continue
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
`
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
function getPrivacyModalTemplate(p) {
|
| 67 |
+
return `
|
| 68 |
+
<div id="appModal-privacyModal" class="modal-overlay" onclick="if(event.target===this)closeAppModal('privacyModal')">
|
| 69 |
+
<div class="modal-card" style="max-width: 600px; max-height: 85vh; overflow-y: auto;">
|
| 70 |
+
<button class="modal-close-btn" onclick="closeAppModal('privacyModal')">×</button>
|
| 71 |
+
<h2 class="modal-title">${p.title}</h2>
|
| 72 |
+
<p class="modal-subtitle mb-6" style="margin-bottom: 24px;">${p.subtitle}</p>
|
| 73 |
+
<div class="legal-document text-xs text-slate-300 space-y-4" style="line-height: 1.6; font-size: 12px; color: #cbd5e1; display: flex; flex-direction: column; gap: 16px; text-align: left;">
|
| 74 |
+
${p.content}
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>`;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function getTermsModalTemplate(t) {
|
| 81 |
+
return `
|
| 82 |
+
<div id="appModal-termsModal" class="modal-overlay" onclick="if(event.target===this)closeAppModal('termsModal')">
|
| 83 |
+
<div class="modal-card" style="max-width: 600px; max-height: 85vh; overflow-y: auto;">
|
| 84 |
+
<button class="modal-close-btn" onclick="closeAppModal('termsModal')">×</button>
|
| 85 |
+
<h2 class="modal-title">${t.title}</h2>
|
| 86 |
+
<p class="modal-subtitle mb-6" style="margin-bottom: 24px;">${t.subtitle}</p>
|
| 87 |
+
<div class="legal-document text-xs text-slate-300 space-y-4" style="line-height: 1.6; font-size: 12px; color: #cbd5e1; display: flex; flex-direction: column; gap: 16px; text-align: left;">
|
| 88 |
+
${t.content}
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>`;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function getShortcutsModalTemplate(rows) {
|
| 95 |
+
return `
|
| 96 |
+
<div id="appModal-shortcutsModal" class="modal-overlay" onclick="if(event.target===this)closeAppModal('shortcutsModal')">
|
| 97 |
+
<div class="modal-card modal-card-sm">
|
| 98 |
+
<button class="modal-close-btn" onclick="closeAppModal('shortcutsModal')">×</button>
|
| 99 |
+
<h2 class="modal-title" style="font-size:1rem">
|
| 100 |
+
<i class="fa-solid fa-keyboard text-accent" style="margin-right:6px"></i>Keyboard Shortcuts
|
| 101 |
+
</h2>
|
| 102 |
+
<p class="text-muted" style="font-size:10px;margin-bottom:16px">Navigate faster with these shortcuts</p>
|
| 103 |
+
<div style="display:flex;flex-direction:column;gap:8px">${rows}</div>
|
| 104 |
+
<p style="color:#333;font-size:9px;margin-top:14px;text-align:center">Press <strong style="color:#555">Esc</strong> to close</p>
|
| 105 |
+
</div>
|
| 106 |
+
</div>`;
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
function getOnboardFormTemplate(user) {
|
| 110 |
+
return `
|
| 111 |
+
<div class="auth-card-header">
|
| 112 |
+
<img src="${user.picture}" alt="" class="auth-avatar" referrerpolicy="no-referrer">
|
| 113 |
+
<h2 class="auth-title">Welcome</h2>
|
| 114 |
+
<p class="auth-subtitle">Choose a display name for your account</p>
|
| 115 |
+
</div>
|
| 116 |
+
<div class="auth-onboard-form">
|
| 117 |
+
<label class="auth-label">Display Name</label>
|
| 118 |
+
<input id="auth-username-input" type="text" class="auth-input" maxlength="30"
|
| 119 |
+
placeholder="e.g. Aarav" value="${user.name.split(' ')[0] || ''}" autocomplete="off">
|
| 120 |
+
<p id="auth-onboard-error" class="auth-error hidden"></p>
|
| 121 |
+
<button id="auth-onboard-submit" class="auth-submit-btn" onclick="_submitOnboarding()">
|
| 122 |
+
Continue
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
`;
|
| 126 |
+
}
|
frontend/js/vehicles.js
CHANGED
|
@@ -154,14 +154,17 @@ document.addEventListener('click', e => {
|
|
| 154 |
|
| 155 |
// ---- Tab switching — updates both sidebar + mobile bottom nav ----
|
| 156 |
function switchTab(tab) {
|
| 157 |
-
|
|
|
|
|
|
|
| 158 |
|
| 159 |
allTabs.forEach(t => {
|
| 160 |
-
// Content panels
|
| 161 |
const el = document.getElementById('tab-' + t);
|
| 162 |
-
if (el)
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
-
// Desktop sidebar nav items
|
| 165 |
const nav = document.getElementById('nav-' + t);
|
| 166 |
if (nav) {
|
| 167 |
if (tab === t) {
|
|
@@ -173,17 +176,29 @@ function switchTab(tab) {
|
|
| 173 |
}
|
| 174 |
}
|
| 175 |
|
| 176 |
-
// Mobile bottom nav items
|
| 177 |
const mobNav = document.getElementById('mob-nav-' + t);
|
| 178 |
-
if (mobNav)
|
| 179 |
-
mobNav.classList.toggle('active', tab === t);
|
| 180 |
-
if (tab === 'reports' && t === 'reports') {
|
| 181 |
-
mobNav.classList.remove('notify-glow');
|
| 182 |
-
}
|
| 183 |
-
}
|
| 184 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
|
|
|
|
|
|
|
|
|
|
| 187 |
// =========== Toast System ===========
|
| 188 |
function showToast(message, type) {
|
| 189 |
type = type || 'info';
|
|
@@ -196,12 +211,22 @@ function switchTab(tab) {
|
|
| 196 |
}
|
| 197 |
|
| 198 |
// =========== Keyboard Shortcuts ===========
|
| 199 |
-
const TAB_KEYS = { '1': 'about', '2': 'overview', '3': '
|
| 200 |
function downloadArtifacts() {
|
| 201 |
const vid = document.body.dataset.lastVideoId;
|
| 202 |
if (vid) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
window.open(`bundle/${vid}`, '_blank');
|
| 204 |
-
showToast('
|
| 205 |
}
|
| 206 |
}
|
| 207 |
|
|
@@ -266,6 +291,12 @@ function switchTab(tab) {
|
|
| 266 |
details: text,
|
| 267 |
timestamp: new Date().toISOString()
|
| 268 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
const res = await fetch('api/feedback', {
|
| 270 |
method: 'POST',
|
| 271 |
headers: { 'Content-Type': 'application/json' },
|
|
@@ -274,8 +305,23 @@ function switchTab(tab) {
|
|
| 274 |
if (res.ok) {
|
| 275 |
showToast('Thank you for your feedback!', 'success');
|
| 276 |
document.getElementById('fb-text').value = '';
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active'));
|
| 280 |
|
| 281 |
// Reset Emojis
|
|
@@ -600,7 +646,14 @@ function switchTab(tab) {
|
|
| 600 |
|
| 601 |
// Original init() logic
|
| 602 |
const raw = sessionStorage.getItem('funky_run');
|
| 603 |
-
if (!raw) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
_params = JSON.parse(raw);
|
| 606 |
|
|
@@ -615,6 +668,9 @@ function switchTab(tab) {
|
|
| 615 |
// Sync current active tab from session if set
|
| 616 |
const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
|
| 617 |
switchTab(activeTab);
|
|
|
|
|
|
|
|
|
|
| 618 |
}
|
| 619 |
|
| 620 |
// =========== Update functions ===========
|
|
@@ -731,8 +787,12 @@ function switchTab(tab) {
|
|
| 731 |
function startNewAnalysis() {
|
| 732 |
sessionStorage.clear();
|
| 733 |
_params = null;
|
| 734 |
-
//
|
| 735 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
}
|
| 737 |
function updateBreakdown(classIn, classOut) {
|
| 738 |
const container = document.getElementById('class-breakdown');
|
|
@@ -841,7 +901,6 @@ function switchTab(tab) {
|
|
| 841 |
const stride = parseInt(document.getElementById('sv-stride').textContent);
|
| 842 |
const reportFmt = document.getElementById('sv-report').value;
|
| 843 |
const annotated = document.getElementById('sv-annotated').classList.contains('active');
|
| 844 |
-
_alpha = parseFloat(document.getElementById('sv-smoothing').textContent) || 0.25;
|
| 845 |
|
| 846 |
// Annotation Options
|
| 847 |
const annotated_options = {
|
|
@@ -867,9 +926,21 @@ function switchTab(tab) {
|
|
| 867 |
// Lock settings
|
| 868 |
lockSettings();
|
| 869 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
// Switch to overview
|
| 871 |
switchTab('overview');
|
| 872 |
-
document.getElementById('proc-label').innerText = '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
|
| 874 |
// Reset Run Tab Results to Awaiting
|
| 875 |
|
|
@@ -879,11 +950,14 @@ function switchTab(tab) {
|
|
| 879 |
<span class="text-xs font-semibold">Executing inference pipeline... results pending</span>
|
| 880 |
</div>`;
|
| 881 |
|
| 882 |
-
// Update
|
| 883 |
const repIcon = document.getElementById('reports-pending-icon');
|
| 884 |
-
if (repIcon)
|
|
|
|
|
|
|
|
|
|
| 885 |
const repText = document.getElementById('reports-pending-text');
|
| 886 |
-
if (repText) repText.innerText = '
|
| 887 |
|
| 888 |
|
| 889 |
// Start WebSocket
|
|
@@ -914,6 +988,7 @@ function switchTab(tab) {
|
|
| 914 |
};
|
| 915 |
|
| 916 |
let processingDone = false;
|
|
|
|
| 917 |
|
| 918 |
ws.onclose = () => {
|
| 919 |
console.log('WS Closed');
|
|
@@ -926,7 +1001,7 @@ function switchTab(tab) {
|
|
| 926 |
<span class="text-xs font-semibold mb-1">Processing connection was lost.</span>
|
| 927 |
<span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span>
|
| 928 |
<button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
|
| 929 |
-
|
| 930 |
</button>
|
| 931 |
</div>`;
|
| 932 |
}
|
|
@@ -939,6 +1014,11 @@ function switchTab(tab) {
|
|
| 939 |
ws.onmessage = e => {
|
| 940 |
const d = JSON.parse(e.data);
|
| 941 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
// Hide empty state on first data
|
| 943 |
const emptyState = document.getElementById('stats-empty-state');
|
| 944 |
if (emptyState) emptyState.style.display = 'none';
|
|
@@ -952,7 +1032,7 @@ function switchTab(tab) {
|
|
| 952 |
<span class="text-xs font-semibold mb-1">Inference pipeline failed.</span>
|
| 953 |
<span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span>
|
| 954 |
<button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
|
| 955 |
-
|
| 956 |
</button>
|
| 957 |
</div>`;
|
| 958 |
return;
|
|
@@ -960,9 +1040,19 @@ function switchTab(tab) {
|
|
| 960 |
|
| 961 |
if (d.done) {
|
| 962 |
processingDone = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
document.getElementById('proc-label').innerText = 'Complete';
|
| 964 |
document.getElementById('proc-bar').style.width = '100%';
|
| 965 |
document.getElementById('proc-pct').innerText = '100%';
|
|
|
|
| 966 |
// Force frame counter to n/n
|
| 967 |
const framesEl = document.getElementById('proc-frames');
|
| 968 |
if (framesEl) {
|
|
@@ -975,11 +1065,16 @@ function switchTab(tab) {
|
|
| 975 |
|
| 976 |
|
| 977 |
// GLOW NOTIFICATION: Let the user know artifacts are ready
|
| 978 |
-
const
|
| 979 |
-
if (
|
| 980 |
-
|
| 981 |
}
|
| 982 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
|
| 984 |
document.getElementById('run-results-content').innerHTML =
|
| 985 |
detailRow('Inference Time', (d.processing_time || 0).toFixed(2) + ' sec') +
|
|
@@ -1011,10 +1106,10 @@ function switchTab(tab) {
|
|
| 1011 |
jsonToggle.closest('.s-row').classList.add('disabled');
|
| 1012 |
}
|
| 1013 |
|
| 1014 |
-
// NOTIFY USER: Glow the
|
| 1015 |
-
const
|
| 1016 |
-
if (
|
| 1017 |
-
|
| 1018 |
}
|
| 1019 |
const csvToggle = document.getElementById('sv-export-csv');
|
| 1020 |
if (csvToggle) {
|
|
@@ -1253,7 +1348,7 @@ function switchTab(tab) {
|
|
| 1253 |
Want to try another video?
|
| 1254 |
</p>
|
| 1255 |
<p style="color:#a89f97;font-size:11px;margin:0">
|
| 1256 |
-
|
| 1257 |
</p>
|
| 1258 |
<div id="retry-bubble-arrow"></div>
|
| 1259 |
`;
|
|
@@ -1265,14 +1360,14 @@ function switchTab(tab) {
|
|
| 1265 |
portal.appendChild(bubble);
|
| 1266 |
|
| 1267 |
if (isMobile) {
|
| 1268 |
-
// 7 icons in bottom nav. Settings =
|
| 1269 |
-
// Center of
|
| 1270 |
const navH = parseInt(
|
| 1271 |
getComputedStyle(document.documentElement)
|
| 1272 |
.getPropertyValue('--mob-nav-h') || '68', 10
|
| 1273 |
);
|
| 1274 |
const vpW = window.innerWidth;
|
| 1275 |
-
const settingsCenterX = (
|
| 1276 |
const bubbleW = bubble.offsetWidth || 220;
|
| 1277 |
const leftPx = Math.max(8, Math.min(settingsCenterX - bubbleW / 2, vpW - bubbleW - 8));
|
| 1278 |
|
|
@@ -1330,6 +1425,122 @@ function switchTab(tab) {
|
|
| 1330 |
}, 6000);
|
| 1331 |
}
|
| 1332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1333 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1334 |
// Phase 1: instant visual — show the shell immediately on first paint
|
| 1335 |
const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
|
|
|
|
| 154 |
|
| 155 |
// ---- Tab switching — updates both sidebar + mobile bottom nav ----
|
| 156 |
function switchTab(tab) {
|
| 157 |
+
console.log('[UrbanFlow] Switching to tab:', tab);
|
| 158 |
+
|
| 159 |
+
const allTabs = ['about', 'overview', 'results', 'settings', 'help', 'feedback', 'profile'];
|
| 160 |
|
| 161 |
allTabs.forEach(t => {
|
|
|
|
| 162 |
const el = document.getElementById('tab-' + t);
|
| 163 |
+
if (el) {
|
| 164 |
+
el.classList.toggle('hidden', tab !== t);
|
| 165 |
+
if (tab === t) console.log('[UrbanFlow] Tab visible:', t);
|
| 166 |
+
}
|
| 167 |
|
|
|
|
| 168 |
const nav = document.getElementById('nav-' + t);
|
| 169 |
if (nav) {
|
| 170 |
if (tab === t) {
|
|
|
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
|
|
|
| 179 |
const mobNav = document.getElementById('mob-nav-' + t);
|
| 180 |
+
if (mobNav) mobNav.classList.toggle('active', tab === t);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
});
|
| 182 |
+
|
| 183 |
+
// Refresh Profile UI every switch
|
| 184 |
+
if (typeof populateProfileUI === 'function') populateProfileUI();
|
| 185 |
+
if (tab === 'profile') populateProfileTab();
|
| 186 |
+
|
| 187 |
+
// Stop glow notification when user views Results
|
| 188 |
+
if (tab === 'results') {
|
| 189 |
+
const mobResults = document.getElementById('mob-nav-results');
|
| 190 |
+
if (mobResults) mobResults.classList.remove('notify-glow');
|
| 191 |
+
const navResults = document.getElementById('nav-results');
|
| 192 |
+
if (navResults) navResults.classList.remove('notify-glow');
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Store active tab
|
| 196 |
+
sessionStorage.setItem('uf_active_tab', tab);
|
| 197 |
}
|
| 198 |
|
| 199 |
+
// Ensure global access
|
| 200 |
+
window.switchTab = switchTab;
|
| 201 |
+
|
| 202 |
// =========== Toast System ===========
|
| 203 |
function showToast(message, type) {
|
| 204 |
type = type || 'info';
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
// =========== Keyboard Shortcuts ===========
|
| 214 |
+
const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'results', '4': 'settings', '5': 'help', '6': 'feedback', '7': 'profile' };
|
| 215 |
function downloadArtifacts() {
|
| 216 |
const vid = document.body.dataset.lastVideoId;
|
| 217 |
if (vid) {
|
| 218 |
+
const btn = document.getElementById('btn-download-bundle');
|
| 219 |
+
if (btn) {
|
| 220 |
+
const originalHTML = btn.innerHTML;
|
| 221 |
+
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> Zipping...`;
|
| 222 |
+
btn.disabled = true;
|
| 223 |
+
setTimeout(() => {
|
| 224 |
+
btn.innerHTML = originalHTML;
|
| 225 |
+
btn.disabled = false;
|
| 226 |
+
}, 4000);
|
| 227 |
+
}
|
| 228 |
window.open(`bundle/${vid}`, '_blank');
|
| 229 |
+
showToast('Preparing download bundle...', 'info');
|
| 230 |
}
|
| 231 |
}
|
| 232 |
|
|
|
|
| 291 |
details: text,
|
| 292 |
timestamp: new Date().toISOString()
|
| 293 |
};
|
| 294 |
+
|
| 295 |
+
// Attach authenticated user email if available
|
| 296 |
+
const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
|
| 297 |
+
if (session && session.email) {
|
| 298 |
+
payload.user_email = session.email;
|
| 299 |
+
}
|
| 300 |
const res = await fetch('api/feedback', {
|
| 301 |
method: 'POST',
|
| 302 |
headers: { 'Content-Type': 'application/json' },
|
|
|
|
| 305 |
if (res.ok) {
|
| 306 |
showToast('Thank you for your feedback!', 'success');
|
| 307 |
document.getElementById('fb-text').value = '';
|
| 308 |
+
|
| 309 |
+
// Reset custom uf-select dropdowns
|
| 310 |
+
['fb-usecase', 'fb-type'].forEach(function(id) {
|
| 311 |
+
var hidden = document.getElementById(id);
|
| 312 |
+
var label = document.getElementById(id + '-label');
|
| 313 |
+
var dropdown = document.getElementById(id + '-dropdown');
|
| 314 |
+
if (hidden) hidden.value = '';
|
| 315 |
+
if (label) {
|
| 316 |
+
label.textContent = id === 'fb-usecase' ? 'Select your use case' : 'General Feedback';
|
| 317 |
+
label.style.color = '#666';
|
| 318 |
+
}
|
| 319 |
+
if (dropdown) {
|
| 320 |
+
dropdown.querySelectorAll('.uf-select-option').forEach(function(opt) {
|
| 321 |
+
opt.classList.remove('uf-select-option-active');
|
| 322 |
+
});
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active'));
|
| 326 |
|
| 327 |
// Reset Emojis
|
|
|
|
| 646 |
|
| 647 |
// Original init() logic
|
| 648 |
const raw = sessionStorage.getItem('funky_run');
|
| 649 |
+
if (!raw) {
|
| 650 |
+
if (typeof showOnboardingPhase === 'function') {
|
| 651 |
+
showOnboardingPhase();
|
| 652 |
+
} else {
|
| 653 |
+
window.location.replace('/');
|
| 654 |
+
}
|
| 655 |
+
return;
|
| 656 |
+
}
|
| 657 |
|
| 658 |
_params = JSON.parse(raw);
|
| 659 |
|
|
|
|
| 668 |
// Sync current active tab from session if set
|
| 669 |
const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
|
| 670 |
switchTab(activeTab);
|
| 671 |
+
|
| 672 |
+
// Populate auth profile UI
|
| 673 |
+
populateProfileUI();
|
| 674 |
}
|
| 675 |
|
| 676 |
// =========== Update functions ===========
|
|
|
|
| 787 |
function startNewAnalysis() {
|
| 788 |
sessionStorage.clear();
|
| 789 |
_params = null;
|
| 790 |
+
// SPA Router
|
| 791 |
+
if (typeof showOnboardingPhase === 'function') {
|
| 792 |
+
showOnboardingPhase();
|
| 793 |
+
} else {
|
| 794 |
+
window.location.replace('/');
|
| 795 |
+
}
|
| 796 |
}
|
| 797 |
function updateBreakdown(classIn, classOut) {
|
| 798 |
const container = document.getElementById('class-breakdown');
|
|
|
|
| 901 |
const stride = parseInt(document.getElementById('sv-stride').textContent);
|
| 902 |
const reportFmt = document.getElementById('sv-report').value;
|
| 903 |
const annotated = document.getElementById('sv-annotated').classList.contains('active');
|
|
|
|
| 904 |
|
| 905 |
// Annotation Options
|
| 906 |
const annotated_options = {
|
|
|
|
| 926 |
// Lock settings
|
| 927 |
lockSettings();
|
| 928 |
|
| 929 |
+
// Freeze annotation chips during processing
|
| 930 |
+
const chipSelector = document.getElementById('chip-selector');
|
| 931 |
+
if (chipSelector) {
|
| 932 |
+
chipSelector.style.pointerEvents = 'none';
|
| 933 |
+
chipSelector.style.opacity = '0.5';
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
// Switch to overview
|
| 937 |
switchTab('overview');
|
| 938 |
+
document.getElementById('proc-label').innerText = 'Connecting...';
|
| 939 |
+
|
| 940 |
+
// Analytics Funnel
|
| 941 |
+
if (typeof trackFunnel === 'function') {
|
| 942 |
+
trackFunnel('PROCESS_STARTED');
|
| 943 |
+
}
|
| 944 |
|
| 945 |
// Reset Run Tab Results to Awaiting
|
| 946 |
|
|
|
|
| 950 |
<span class="text-xs font-semibold">Executing inference pipeline... results pending</span>
|
| 951 |
</div>`;
|
| 952 |
|
| 953 |
+
// Update Results tab pending message
|
| 954 |
const repIcon = document.getElementById('reports-pending-icon');
|
| 955 |
+
if (repIcon) {
|
| 956 |
+
repIcon.className = 'fa-solid fa-satellite-dish animate-pulse text-5xl mb-2';
|
| 957 |
+
repIcon.style.color = '#c89a6c';
|
| 958 |
+
}
|
| 959 |
const repText = document.getElementById('reports-pending-text');
|
| 960 |
+
if (repText) repText.innerText = 'Transmission in progress...';
|
| 961 |
|
| 962 |
|
| 963 |
// Start WebSocket
|
|
|
|
| 988 |
};
|
| 989 |
|
| 990 |
let processingDone = false;
|
| 991 |
+
let firstMessageReceived = false;
|
| 992 |
|
| 993 |
ws.onclose = () => {
|
| 994 |
console.log('WS Closed');
|
|
|
|
| 1001 |
<span class="text-xs font-semibold mb-1">Processing connection was lost.</span>
|
| 1002 |
<span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span>
|
| 1003 |
<button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
|
| 1004 |
+
<i class="fa-solid fa-house mr-1"></i> Home
|
| 1005 |
</button>
|
| 1006 |
</div>`;
|
| 1007 |
}
|
|
|
|
| 1014 |
ws.onmessage = e => {
|
| 1015 |
const d = JSON.parse(e.data);
|
| 1016 |
|
| 1017 |
+
if (!firstMessageReceived) {
|
| 1018 |
+
firstMessageReceived = true;
|
| 1019 |
+
document.getElementById('proc-label').innerText = 'Processing';
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
// Hide empty state on first data
|
| 1023 |
const emptyState = document.getElementById('stats-empty-state');
|
| 1024 |
if (emptyState) emptyState.style.display = 'none';
|
|
|
|
| 1032 |
<span class="text-xs font-semibold mb-1">Inference pipeline failed.</span>
|
| 1033 |
<span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span>
|
| 1034 |
<button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
|
| 1035 |
+
<i class="fa-solid fa-house mr-1"></i> Home
|
| 1036 |
</button>
|
| 1037 |
</div>`;
|
| 1038 |
return;
|
|
|
|
| 1040 |
|
| 1041 |
if (d.done) {
|
| 1042 |
processingDone = true;
|
| 1043 |
+
|
| 1044 |
+
// Stats Tracking (Scoped by email)
|
| 1045 |
+
const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
|
| 1046 |
+
const emailKey = session ? `_${session.email}` : '';
|
| 1047 |
+
|
| 1048 |
+
let currentRuns = parseInt(localStorage.getItem(`uf_total_runs${emailKey}`) || '0');
|
| 1049 |
+
localStorage.setItem(`uf_total_runs${emailKey}`, currentRuns + 1);
|
| 1050 |
+
localStorage.setItem(`uf_last_active${emailKey}`, new Date().toLocaleString());
|
| 1051 |
+
|
| 1052 |
document.getElementById('proc-label').innerText = 'Complete';
|
| 1053 |
document.getElementById('proc-bar').style.width = '100%';
|
| 1054 |
document.getElementById('proc-pct').innerText = '100%';
|
| 1055 |
+
|
| 1056 |
// Force frame counter to n/n
|
| 1057 |
const framesEl = document.getElementById('proc-frames');
|
| 1058 |
if (framesEl) {
|
|
|
|
| 1065 |
|
| 1066 |
|
| 1067 |
// GLOW NOTIFICATION: Let the user know artifacts are ready
|
| 1068 |
+
const resultsMob = document.getElementById('mob-nav-results');
|
| 1069 |
+
if (resultsMob) {
|
| 1070 |
+
resultsMob.classList.add('notify-glow');
|
| 1071 |
}
|
| 1072 |
|
| 1073 |
+
// Show results content immediately (telemetry first, reports load async)
|
| 1074 |
+
const rPendingMsg = document.getElementById('reports-pending-message');
|
| 1075 |
+
if (rPendingMsg) rPendingMsg.classList.add('hidden');
|
| 1076 |
+
const rContentWrap = document.getElementById('results-content-wrap');
|
| 1077 |
+
if (rContentWrap) rContentWrap.classList.remove('hidden');
|
| 1078 |
|
| 1079 |
document.getElementById('run-results-content').innerHTML =
|
| 1080 |
detailRow('Inference Time', (d.processing_time || 0).toFixed(2) + ' sec') +
|
|
|
|
| 1106 |
jsonToggle.closest('.s-row').classList.add('disabled');
|
| 1107 |
}
|
| 1108 |
|
| 1109 |
+
// NOTIFY USER: Glow the results icon in mobile nav
|
| 1110 |
+
const resultsNav = document.getElementById('mob-nav-results');
|
| 1111 |
+
if (resultsNav) {
|
| 1112 |
+
resultsNav.classList.add('notify-glow');
|
| 1113 |
}
|
| 1114 |
const csvToggle = document.getElementById('sv-export-csv');
|
| 1115 |
if (csvToggle) {
|
|
|
|
| 1348 |
Want to try another video?
|
| 1349 |
</p>
|
| 1350 |
<p style="color:#a89f97;font-size:11px;margin:0">
|
| 1351 |
+
Tap <b style="color:#f0ece6">⚙ Settings</b> & click '<b style="color:#f0ece6">Home</b>'
|
| 1352 |
</p>
|
| 1353 |
<div id="retry-bubble-arrow"></div>
|
| 1354 |
`;
|
|
|
|
| 1360 |
portal.appendChild(bubble);
|
| 1361 |
|
| 1362 |
if (isMobile) {
|
| 1363 |
+
// 7 icons in bottom nav. Settings = 4th (index 3).
|
| 1364 |
+
// Center of 4th icon = (3 + 0.5) / 7 = 50% of viewport width.
|
| 1365 |
const navH = parseInt(
|
| 1366 |
getComputedStyle(document.documentElement)
|
| 1367 |
.getPropertyValue('--mob-nav-h') || '68', 10
|
| 1368 |
);
|
| 1369 |
const vpW = window.innerWidth;
|
| 1370 |
+
const settingsCenterX = (3.5 / 7) * vpW;
|
| 1371 |
const bubbleW = bubble.offsetWidth || 220;
|
| 1372 |
const leftPx = Math.max(8, Math.min(settingsCenterX - bubbleW / 2, vpW - bubbleW - 8));
|
| 1373 |
|
|
|
|
| 1425 |
}, 6000);
|
| 1426 |
}
|
| 1427 |
|
| 1428 |
+
|
| 1429 |
+
// =========== Auth & Profile UI ===========
|
| 1430 |
+
|
| 1431 |
+
function populateProfileUI() {
|
| 1432 |
+
const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
|
| 1433 |
+
if (!session) return;
|
| 1434 |
+
|
| 1435 |
+
// Update PFP in Desktop Sidebar
|
| 1436 |
+
const sidebarPfp = document.getElementById('sidebar-profile-pfp-wrap');
|
| 1437 |
+
if (sidebarPfp && session.picture) {
|
| 1438 |
+
sidebarPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`;
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
// Update PFP in Mobile Bottom Nav
|
| 1442 |
+
const mobPfp = document.getElementById('mob-pfp-wrap');
|
| 1443 |
+
if (mobPfp && session.picture) {
|
| 1444 |
+
mobPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`;
|
| 1445 |
+
}
|
| 1446 |
+
|
| 1447 |
+
// Sync Palette Preference
|
| 1448 |
+
const savedPalette = localStorage.getItem('uf_pref_palette') || 'default';
|
| 1449 |
+
const paletteInp = document.getElementById('pref-palette');
|
| 1450 |
+
if (paletteInp) {
|
| 1451 |
+
paletteInp.value = savedPalette;
|
| 1452 |
+
const label = document.getElementById('pref-palette-label');
|
| 1453 |
+
if (label) label.innerText = savedPalette.charAt(0).toUpperCase() + savedPalette.slice(1);
|
| 1454 |
+
}
|
| 1455 |
+
}
|
| 1456 |
+
|
| 1457 |
+
function populateProfileTab() {
|
| 1458 |
+
const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
|
| 1459 |
+
if (!session) return;
|
| 1460 |
+
|
| 1461 |
+
// Identity
|
| 1462 |
+
const pfpLarge = document.getElementById('profile-pfp-large');
|
| 1463 |
+
if (pfpLarge && session.picture) pfpLarge.src = session.picture;
|
| 1464 |
+
|
| 1465 |
+
const emailEl = document.getElementById('profile-email');
|
| 1466 |
+
if (emailEl) emailEl.innerText = session.email;
|
| 1467 |
+
|
| 1468 |
+
const nameInp = document.getElementById('profile-username-input');
|
| 1469 |
+
if (nameInp) nameInp.value = session.username || session.name || '';
|
| 1470 |
+
|
| 1471 |
+
// Stats (Scoped by email)
|
| 1472 |
+
const emailKey = `_${session.email}`;
|
| 1473 |
+
const totalRuns = localStorage.getItem(`uf_total_runs${emailKey}`) || '0';
|
| 1474 |
+
const lastActive = localStorage.getItem(`uf_last_active${emailKey}`) || 'Never';
|
| 1475 |
+
|
| 1476 |
+
if (document.getElementById('profile-total-runs'))
|
| 1477 |
+
document.getElementById('profile-total-runs').innerText = totalRuns;
|
| 1478 |
+
if (document.getElementById('profile-last-active'))
|
| 1479 |
+
document.getElementById('profile-last-active').innerText = lastActive;
|
| 1480 |
+
}
|
| 1481 |
+
|
| 1482 |
+
async function saveProfileUsername() {
|
| 1483 |
+
const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
|
| 1484 |
+
if (!session) return;
|
| 1485 |
+
|
| 1486 |
+
const input = document.getElementById('profile-username-input');
|
| 1487 |
+
const newName = input.value.trim();
|
| 1488 |
+
if (!newName) return showToast('Name cannot be empty', 'error');
|
| 1489 |
+
|
| 1490 |
+
const btn = document.getElementById('btn-save-username');
|
| 1491 |
+
btn.disabled = true;
|
| 1492 |
+
btn.innerText = 'Saving...';
|
| 1493 |
+
|
| 1494 |
+
try {
|
| 1495 |
+
// Save locally first (always works)
|
| 1496 |
+
session.username = newName;
|
| 1497 |
+
if (typeof saveAuthSession === 'function') saveAuthSession(session);
|
| 1498 |
+
|
| 1499 |
+
// Try backend as best-effort
|
| 1500 |
+
fetch('api/auth/onboard', {
|
| 1501 |
+
method: 'POST',
|
| 1502 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1503 |
+
body: JSON.stringify({ email: session.email, username: newName })
|
| 1504 |
+
}).catch(() => {});
|
| 1505 |
+
|
| 1506 |
+
showToast('Profile updated successfully', 'success');
|
| 1507 |
+
populateProfileUI();
|
| 1508 |
+
} catch (err) {
|
| 1509 |
+
showToast('Failed to update profile', 'error');
|
| 1510 |
+
} finally {
|
| 1511 |
+
btn.disabled = false;
|
| 1512 |
+
btn.innerText = 'Save';
|
| 1513 |
+
}
|
| 1514 |
+
}
|
| 1515 |
+
|
| 1516 |
+
function toggleLegalMenu(e) {
|
| 1517 |
+
e.stopPropagation();
|
| 1518 |
+
const menus = ['legal-menu', 'legal-menu-profile'];
|
| 1519 |
+
menus.forEach(m => {
|
| 1520 |
+
const el = document.getElementById(m);
|
| 1521 |
+
if (el) el.classList.toggle('hidden');
|
| 1522 |
+
});
|
| 1523 |
+
|
| 1524 |
+
// Close on click outside
|
| 1525 |
+
const closer = () => {
|
| 1526 |
+
menus.forEach(m => {
|
| 1527 |
+
const el = document.getElementById(m);
|
| 1528 |
+
if (el) el.classList.add('hidden');
|
| 1529 |
+
});
|
| 1530 |
+
window.removeEventListener('click', closer);
|
| 1531 |
+
};
|
| 1532 |
+
window.addEventListener('click', closer);
|
| 1533 |
+
}
|
| 1534 |
+
|
| 1535 |
+
// Palette Persistence
|
| 1536 |
+
document.addEventListener('change', (e) => {
|
| 1537 |
+
if (e.target.id === 'pref-palette') {
|
| 1538 |
+
localStorage.setItem('uf_pref_palette', e.target.value);
|
| 1539 |
+
showToast(`Palette preference saved: ${e.target.value}`, 'success');
|
| 1540 |
+
}
|
| 1541 |
+
});
|
| 1542 |
+
|
| 1543 |
+
|
| 1544 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1545 |
// Phase 1: instant visual — show the shell immediately on first paint
|
| 1546 |
const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
|
frontend/sw.js
CHANGED
|
@@ -2,6 +2,8 @@ const CACHE_NAME = 'urbanflow-v4';
|
|
| 2 |
const ASSETS = [
|
| 3 |
'./css/initial.css',
|
| 4 |
'./css/vehicles.css',
|
|
|
|
|
|
|
| 5 |
'./assets/shuriken.png',
|
| 6 |
'./assets/shurkien_b.png',
|
| 7 |
'./assets/uf_rf.png'
|
|
|
|
| 2 |
const ASSETS = [
|
| 3 |
'./css/initial.css',
|
| 4 |
'./css/vehicles.css',
|
| 5 |
+
'./css/shared.css',
|
| 6 |
+
'./css/auth.css',
|
| 7 |
'./assets/shuriken.png',
|
| 8 |
'./assets/shurkien_b.png',
|
| 9 |
'./assets/uf_rf.png'
|
frontend/vehicles.html
CHANGED
|
@@ -6,13 +6,13 @@
|
|
| 6 |
<meta name="color-scheme" content="dark">
|
| 7 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 8 |
<title>UrbanFlow</title>
|
| 9 |
-
<link rel="icon" type="image/png" href="assets/
|
| 10 |
<link rel="manifest" href="manifest.json">
|
| 11 |
<meta name="theme-color" content="#000000">
|
| 12 |
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 13 |
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
| 14 |
<meta name="apple-mobile-web-app-title" content="UrbanFlow">
|
| 15 |
-
<link rel="apple-touch-icon" href="assets/
|
| 16 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 17 |
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 18 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
@@ -20,7 +20,30 @@
|
|
| 20 |
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
|
| 21 |
rel="stylesheet">
|
| 22 |
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
|
|
| 23 |
<link rel="stylesheet" href="css/vehicles.css">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</head>
|
| 25 |
|
| 26 |
<body class="bg-black text-white h-screen w-screen flex" style="overflow:hidden">
|
|
@@ -31,16 +54,18 @@
|
|
| 31 |
|
| 32 |
<!-- Mobile Legal Overflow -->
|
| 33 |
<div class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center">
|
| 34 |
-
<button onclick="toggleLegalMenu(event)" class="text-[#a89f97] hover:text-white p-2 transition-colors" aria-label="
|
| 35 |
-
<i class="fa-solid fa-ellipsis-vertical text-lg"></i>
|
| 36 |
</button>
|
| 37 |
<div id="legal-menu" class="hidden absolute right-0 top-full mt-2 w-48 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 overflow-hidden">
|
| 38 |
<button onclick="openAppModal('privacyModal'); toggleLegalMenu(event)" class="w-full text-left px-4 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[#a89f97] hover:text-white hover:bg-[#111] border-b border-[#1a1a1a] transition-all">
|
| 39 |
Privacy Policy
|
| 40 |
</button>
|
| 41 |
-
<button onclick="openAppModal('termsModal'); toggleLegalMenu(event)" class="w-full text-left px-4 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[#a89f97] hover:text-white hover:bg-[#111] transition-all">
|
| 42 |
Terms & Conditions
|
| 43 |
</button>
|
|
|
|
|
|
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
</div>
|
|
@@ -57,15 +82,11 @@
|
|
| 57 |
</a>
|
| 58 |
<a onclick="switchTab('overview')" id="nav-overview"
|
| 59 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 60 |
-
<i class="fa-solid fa-desktop w-6"></i> <span class="font-medium">
|
| 61 |
-
</a>
|
| 62 |
-
<a onclick="switchTab('run-details')" id="nav-run-details"
|
| 63 |
-
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 64 |
-
<i class="fa-solid fa-microchip w-6"></i> <span class="font-medium">Run</span>
|
| 65 |
</a>
|
| 66 |
-
<a onclick="switchTab('
|
| 67 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 68 |
-
<i class="fa-solid fa-file-lines w-6"></i> <span class="font-medium">
|
| 69 |
</a>
|
| 70 |
<a onclick="switchTab('settings')" id="nav-settings"
|
| 71 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
|
@@ -79,6 +100,11 @@
|
|
| 79 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 80 |
<i class="fa-solid fa-comment-dots w-6"></i> <span class="font-medium">Feedback</span>
|
| 81 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</nav>
|
| 83 |
<div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
|
| 84 |
style="border-color:#2a2a2a">
|
|
@@ -90,29 +116,24 @@
|
|
| 90 |
class="text-[10px] font-bold uppercase tracking-widest transition w-full text-center py-1 rounded"
|
| 91 |
style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
|
| 92 |
onmouseout="this.style.color='#a89f97'">Terms & Conditions</button>
|
| 93 |
-
<p class="text-[11px] font-medium mt-
|
| 94 |
</div>
|
| 95 |
</aside>
|
| 96 |
|
| 97 |
<!-- Mobile Navigation (hidden on desktop) -->
|
| 98 |
<div
|
| 99 |
-
class="mobile-nav hidden fixed top-0 left-0 right-0 z-30 bg-black border-b border-slate-800 px-
|
| 100 |
<img src="assets/uf_rf.png" alt="UF" class="h-8">
|
| 101 |
-
<div class="flex gap-
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
class="
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
<
|
| 111 |
-
class="fa-solid fa-sliders"></i></button>
|
| 112 |
-
<button onclick="switchTab('help')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
|
| 113 |
-
class="fa-solid fa-circle-question"></i></button>
|
| 114 |
-
<button onclick="switchTab('feedback')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
|
| 115 |
-
class="fa-solid fa-comment-dots"></i></button>
|
| 116 |
</div>
|
| 117 |
</div>
|
| 118 |
|
|
@@ -121,6 +142,41 @@
|
|
| 121 |
|
| 122 |
<main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
<!-- TAB: About -->
|
| 125 |
<div id="tab-about" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 126 |
<div class="bg-black border rounded-xl p-12 shadow-2xl space-y-8 flex flex-col justify-center"
|
|
@@ -158,7 +214,7 @@
|
|
| 158 |
</p>
|
| 159 |
</div>
|
| 160 |
|
| 161 |
-
<div class="grid grid-cols-
|
| 162 |
style="border-color:#1a1a1a">
|
| 163 |
<div class="space-y-4">
|
| 164 |
<h4 class="font-bold text-[13px] uppercase tracking-wider" style="color:#f0ece6">What You Will Get Here</h4>
|
|
@@ -173,7 +229,7 @@
|
|
| 173 |
</li>
|
| 174 |
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 175 |
style="color:#c89a6c"></i>
|
| 176 |
-
<span>Congestion Analytics,Peak Detection, and temporal Trends</span>
|
| 177 |
</li>
|
| 178 |
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 179 |
style="color:#c89a6c"></i>
|
|
@@ -187,13 +243,30 @@
|
|
| 187 |
</h4>
|
| 188 |
<p class="text-xs leading-relaxed" style="color:#a89f97">
|
| 189 |
UrbanFlow delivers an estimated accuracy of ±5–8% on dense mixed-traffic footage. Results
|
| 190 |
-
may vary slightly across runs due to frame-by-frame inference variability
|
| 191 |
</p>
|
| 192 |
<p class="text-xs leading-relaxed" style="color:#a89f97">
|
| 193 |
For research or planning use, process the same footage 2–3 times
|
| 194 |
and average the results for improved reliability.
|
| 195 |
</p>
|
| 196 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
</div>
|
| 198 |
|
| 199 |
<div class="text-center pt-6 border-t" style="border-color:#1a1a1a">
|
|
@@ -212,42 +285,6 @@
|
|
| 212 |
</div>
|
| 213 |
|
| 214 |
|
| 215 |
-
<!-- Progress Bar (shared) -->
|
| 216 |
-
<div id="progress-bar-wrapper"
|
| 217 |
-
class="w-full bg-neutral-950 rounded-xl px-6 py-4 border border-neutral-800 shadow-sm flex items-center justify-between flex-shrink-0">
|
| 218 |
-
<div class="flex items-center space-x-4 flex-1 mr-6">
|
| 219 |
-
<span class="text-[11px] font-black text-white uppercase tracking-wider whitespace-nowrap"
|
| 220 |
-
id="proc-label">Waiting</span>
|
| 221 |
-
<div class="flex-1 h-2 bg-[#111111] rounded-full overflow-hidden relative border border-[#1a1a1a]">
|
| 222 |
-
<div id="proc-bar" class="h-full bg-[#444444] rounded-full transition-all duration-500 ease-out"
|
| 223 |
-
style="width: 0%"></div>
|
| 224 |
-
</div>
|
| 225 |
-
</div>
|
| 226 |
-
<div class="flex items-center space-x-6 text-xs font-bold text-white whitespace-nowrap">
|
| 227 |
-
<span id="proc-frames">Awaiting Input</span>
|
| 228 |
-
<span id="proc-pct">Idle</span>
|
| 229 |
-
<div class="uf-select-wrap" id="live-palette-wrap">
|
| 230 |
-
<div class="uf-select-trigger" id="live-palette-trigger" onclick="ufSelectToggle('live-palette')">
|
| 231 |
-
<span class="uf-select-label" id="live-palette-label">Default</span>
|
| 232 |
-
<i class="fa-solid fa-chevron-down uf-select-arrow" id="live-palette-arrow"></i>
|
| 233 |
-
</div>
|
| 234 |
-
<div class="uf-select-dropdown hidden" id="live-palette-dropdown">
|
| 235 |
-
<div class="uf-select-option" data-value="default" onclick="ufSelectPick('live-palette','default','Default')">Default</div>
|
| 236 |
-
<div class="uf-select-option" data-value="vibrant" onclick="ufSelectPick('live-palette','vibrant','Vibrant')">Vibrant</div>
|
| 237 |
-
<div class="uf-select-option" data-value="corporate" onclick="ufSelectPick('live-palette','corporate','Corporate')">Corporate</div>
|
| 238 |
-
<div class="uf-select-option" data-value="neon" onclick="ufSelectPick('live-palette','neon','Neon Night')">Neon Night</div>
|
| 239 |
-
<div class="uf-select-option" data-value="earth" onclick="ufSelectPick('live-palette','earth','Earth Tones')">Earth Tones</div>
|
| 240 |
-
<div class="uf-select-option" data-value="ocean" onclick="ufSelectPick('live-palette','ocean','Ocean Breeze')">Ocean Breeze</div>
|
| 241 |
-
<div class="uf-select-option" data-value="sunset" onclick="ufSelectPick('live-palette','sunset','Sunset Glow')">Sunset Glow</div>
|
| 242 |
-
<div class="uf-select-option" data-value="midnight" onclick="ufSelectPick('live-palette','midnight','Midnight Deep')">Midnight Deep</div>
|
| 243 |
-
<div class="uf-select-option" data-value="gold" onclick="ufSelectPick('live-palette','gold','Monochrome Gold')">Monochrome Gold</div>
|
| 244 |
-
</div>
|
| 245 |
-
<input type="hidden" id="live-palette-select" value="default">
|
| 246 |
-
</div>
|
| 247 |
-
</div>
|
| 248 |
-
</div>
|
| 249 |
-
|
| 250 |
-
<!-- TAB: Overview -->
|
| 251 |
<div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden"
|
| 252 |
style="position:relative">
|
| 253 |
|
|
@@ -339,88 +376,17 @@
|
|
| 339 |
<div class="flex-1 w-full relative min-h-[220px]">
|
| 340 |
<canvas id="flowChart"></canvas>
|
| 341 |
</div>
|
| 342 |
-
</div>
|
| 343 |
-
|
| 344 |
-
</div>
|
| 345 |
-
|
| 346 |
-
</div>
|
| 347 |
-
|
| 348 |
-
<!-- TAB: Run -->
|
| 349 |
-
<div id="tab-run-details" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 350 |
-
<div class="space-y-6 w-full max-w-[1400px] mx-auto">
|
| 351 |
-
|
| 352 |
-
<!-- HERO: Process Analytics -->
|
| 353 |
-
<div id="run-results-card" class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm overflow-hidden flex flex-col"
|
| 354 |
-
style="border-color:#2a2a2a">
|
| 355 |
-
<div class="px-6 py-4 border-b flex flex-col lg:flex-row justify-between items-center gap-4"
|
| 356 |
-
style="border-color:#1a1a1a;background:#050505">
|
| 357 |
-
<div class="text-center lg:text-left">
|
| 358 |
-
<h3 class="font-bold text-sm" style="color:#f0ece6">Process Analytics</h3>
|
| 359 |
-
<p class="text-[10px] mt-0.5 uppercase tracking-widest font-medium"
|
| 360 |
-
style="color:#a89f97">
|
| 361 |
-
Execution Telemetry</p>
|
| 362 |
-
</div>
|
| 363 |
-
<div class="flex items-center gap-3">
|
| 364 |
-
</div>
|
| 365 |
-
</div>
|
| 366 |
-
<div class="p-8">
|
| 367 |
-
<div id="run-results-content" class="grid grid-cols-3 gap-12">
|
| 368 |
-
<div class="flex flex-col items-center justify-center p-12 rounded-2xl col-span-3 text-center w-full"
|
| 369 |
-
style="color:#555; min-height: 180px;">
|
| 370 |
-
<i class="fa-solid fa-chart-line text-4xl mb-4" style="color:#3a3a3a;"></i>
|
| 371 |
-
<span class="text-[11px] font-bold uppercase tracking-[0.2em] block w-full text-center">Initiate a run to view performance insights</span>
|
| 372 |
-
</div>
|
| 373 |
-
</div>
|
| 374 |
-
</div>
|
| 375 |
-
</div>
|
| 376 |
-
|
| 377 |
-
<!-- Technical Context Row -->
|
| 378 |
-
<div class="grid grid-cols-2 gap-6">
|
| 379 |
-
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
|
| 380 |
-
style="border-color:#2a2a2a">
|
| 381 |
-
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 382 |
-
<h3 class="font-bold text-sm" style="color:#f0ece6">Stream Source Profile</h3>
|
| 383 |
-
</div>
|
| 384 |
-
<div class="p-6 space-y-4" id="panel-video"></div>
|
| 385 |
-
</div>
|
| 386 |
-
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
|
| 387 |
-
style="border-color:#2a2a2a">
|
| 388 |
-
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 389 |
-
<h3 class="font-bold text-sm" style="color:#f0ece6">System Resource Utilization</h3>
|
| 390 |
-
</div>
|
| 391 |
-
<div class="p-6 space-y-4" id="panel-perf"></div>
|
| 392 |
-
</div>
|
| 393 |
-
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
|
| 394 |
-
style="border-color:#2a2a2a">
|
| 395 |
-
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 396 |
-
<h3 class="font-bold text-sm" style="color:#f0ece6">Model Architecture & Logic</h3>
|
| 397 |
-
</div>
|
| 398 |
-
<div class="p-6 space-y-4" id="panel-model"></div>
|
| 399 |
-
</div>
|
| 400 |
-
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
|
| 401 |
-
style="border-color:#2a2a2a">
|
| 402 |
-
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 403 |
-
<h3 class="font-bold text-sm" style="color:#f0ece6">Inference Parameters</h3>
|
| 404 |
-
</div>
|
| 405 |
-
<div class="p-6 space-y-4" id="panel-infer"></div>
|
| 406 |
-
</div>
|
| 407 |
-
</div>
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
</div>
|
| 412 |
</div>
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
<!-- TAB: Reports -->
|
| 417 |
-
<div id="tab-reports" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 418 |
<div id="reports-pending-message"
|
| 419 |
class="mb-4 text-center p-8 flex flex-col items-center justify-center gap-4"
|
| 420 |
style="min-height: 60vh;">
|
| 421 |
<i class="fa-solid fa-hourglass-half text-5xl mb-2" style="color:#3a3230;"></i>
|
| 422 |
<div style="color:#c89a6c; font-size:13px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase;">
|
| 423 |
-
|
| 424 |
</div>
|
| 425 |
<span id="reports-pending-text"
|
| 426 |
class="text-xs font-medium tracking-wide uppercase leading-relaxed text-center"
|
|
@@ -429,31 +395,91 @@
|
|
| 429 |
</span>
|
| 430 |
</div>
|
| 431 |
|
| 432 |
-
<
|
| 433 |
-
|
| 434 |
-
<!--
|
| 435 |
-
<div class="
|
| 436 |
-
<
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
<span class="info-tip">Passenger Car Units (IRC:106-1990). Converts heterogeneous Indian
|
| 441 |
-
traffic into standardized units for road capacity analysis.</span></span>
|
| 442 |
-
</h3>
|
| 443 |
-
</div>
|
| 444 |
-
<div class="flex-1 flex items-center justify-center" id="pcu-stats-card">
|
| 445 |
-
<div class="text-center text-slate-600 text-xs"><i></i><br>Available after processing</div>
|
| 446 |
-
</div>
|
| 447 |
</div>
|
| 448 |
-
</div>
|
| 449 |
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
</div>
|
| 458 |
</div>
|
| 459 |
|
|
@@ -461,7 +487,7 @@
|
|
| 461 |
<div id="tab-settings" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 462 |
<div class="grid grid-cols-2 gap-6 w-full">
|
| 463 |
<!-- Processing Parameters -->
|
| 464 |
-
<div class="col-span-1 bg-black rounded-xl border shadow-sm overflow-hidden flex flex-col"
|
| 465 |
style="border-color:#2a2a2a">
|
| 466 |
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 467 |
<h3 class="font-bold text-white text-sm flex items-center">Inference Configuration Profile
|
|
@@ -475,7 +501,7 @@
|
|
| 475 |
<p class="text-[10px] mt-0.1 uppercase tracking-widest font-medium" style="color:#a89f97">
|
| 476 |
Auto-configured pipeline parameters</p>
|
| 477 |
</div>
|
| 478 |
-
<div class="px-6 py-2
|
| 479 |
<div class="s-row" data-param="imgsz">
|
| 480 |
<div>
|
| 481 |
<div class="text-xs font-semibold text-slate-300 flex items-center">Image Size
|
|
@@ -541,24 +567,6 @@
|
|
| 541 |
onclick="stepParam('stride',1)">›</button></div>
|
| 542 |
</div>
|
| 543 |
|
| 544 |
-
<div class="s-row" data-param="smoothing">
|
| 545 |
-
<div>
|
| 546 |
-
<div class="text-xs font-semibold text-slate-300 flex items-center gap-1">
|
| 547 |
-
<span>Congestion Smoothing</span>
|
| 548 |
-
<span class="info-wrap">
|
| 549 |
-
<span class="info-btn" style="background:#1a1a1a;color:#888"><i
|
| 550 |
-
class="fa-solid fa-info"></i></span>
|
| 551 |
-
<span class="info-tip">Reduces jitter/noise in the line chart. Low values
|
| 552 |
-
(0.05-0.2) create very smooth trends; high values (0.8+) show raw spiky
|
| 553 |
-
data.</span>
|
| 554 |
-
</span>
|
| 555 |
-
</div>
|
| 556 |
-
<div class="text-[10px] text-slate-500">EMA Alpha factor for the rolling average</div>
|
| 557 |
-
</div>
|
| 558 |
-
<div class="s-stepper"><button onclick="stepParam('smoothing',-0.05)">‹</button><span
|
| 559 |
-
class="s-val" id="sv-smoothing">0.25</span><button
|
| 560 |
-
onclick="stepParam('smoothing',0.05)">›</button></div>
|
| 561 |
-
</div>
|
| 562 |
</div>
|
| 563 |
</div>
|
| 564 |
|
|
@@ -661,14 +669,6 @@
|
|
| 661 |
<div class="toggle-thumb"></div>
|
| 662 |
</div>
|
| 663 |
</div>
|
| 664 |
-
<div class="s-row hidden">
|
| 665 |
-
<div>
|
| 666 |
-
<div class="text-xs font-semibold text-slate-300">Interface Mode</div>
|
| 667 |
-
<div class="text-[10px] text-slate-500">Locked to Professional Dark</div>
|
| 668 |
-
</div>
|
| 669 |
-
<div class="text-xs font-bold text-white px-3 py-1 bg-slate-800 rounded-full">Dark Mode Only
|
| 670 |
-
</div>
|
| 671 |
-
</div>
|
| 672 |
</div>
|
| 673 |
</div>
|
| 674 |
|
|
@@ -681,12 +681,12 @@
|
|
| 681 |
</button>
|
| 682 |
</div>
|
| 683 |
|
| 684 |
-
<!--
|
| 685 |
<div class="col-span-3 pb-4 hidden flex justify-center" id="new-analysis-wrap">
|
| 686 |
<button onclick="startNewAnalysis()"
|
| 687 |
class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg hover:scale-105 active:scale-95"
|
| 688 |
-
style="background:var(--cocoa
|
| 689 |
-
<span>
|
| 690 |
</button>
|
| 691 |
</div>
|
| 692 |
</div>
|
|
@@ -755,8 +755,8 @@
|
|
| 755 |
</button>
|
| 756 |
<div id="h3-ans" class="hidden px-6 pb-6 text-xs leading-relaxed"
|
| 757 |
style="color:#777">
|
| 758 |
-
To analyze a new video, go to 'Settings' and click '
|
| 759 |
-
This resets current session
|
| 760 |
to the Home screen.
|
| 761 |
</div>
|
| 762 |
</div>
|
|
@@ -1130,174 +1130,80 @@
|
|
| 1130 |
</div>
|
| 1131 |
</div>
|
| 1132 |
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
</ul>
|
| 1158 |
-
<p style="color:#555;font-size:10px;margin-top:20px">— Team UrbanFlow</p>
|
| 1159 |
-
</div>
|
| 1160 |
-
</div>
|
| 1161 |
-
|
| 1162 |
-
<!-- Terms Modal -->
|
| 1163 |
-
<div id="appModal-termsModal" onclick="if(event.target===this)closeAppModal('termsModal')"
|
| 1164 |
-
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
|
| 1165 |
-
<div
|
| 1166 |
-
style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
|
| 1167 |
-
<button onclick="closeAppModal('termsModal')"
|
| 1168 |
-
style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">×</button>
|
| 1169 |
-
<h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Terms & Conditions</h2>
|
| 1170 |
-
<p style="color:#a89f97;font-size:11px;margin-bottom:20px">By using this application, you agree to the
|
| 1171 |
-
following terms.</p>
|
| 1172 |
-
<p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px">You can:</p>
|
| 1173 |
-
<ul
|
| 1174 |
-
style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px">
|
| 1175 |
-
<li>Use this demo to evaluate UrbanFlow’s traffic detection and analytics capabilities.</li>
|
| 1176 |
-
<li>Export reports, annotated video outputs, and data artifacts to your own device.</li>
|
| 1177 |
-
<li>Share feedback, feature requests, or questions with us at <strong
|
| 1178 |
-
style="color:#c89a6c">support.urbanflow365@gmail.com</strong>.</li>
|
| 1179 |
-
<li>Reference this demo in research or internal evaluation, with proper attribution.</li>
|
| 1180 |
-
</ul>
|
| 1181 |
-
<p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px">You cannot:</p>
|
| 1182 |
-
<ul
|
| 1183 |
-
style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px">
|
| 1184 |
-
<li>Commercially redistribute outputs or present them as your own product’s capability.</li>
|
| 1185 |
-
<li>Reverse-engineer, extract, or attempt to replicate the underlying model or processing pipeline.</li>
|
| 1186 |
-
<li>Use the platform for unlawful, harmful, or safety-critical operational purposes.</li>
|
| 1187 |
-
<li>Misrepresent demo outputs as certified or regulatory-grade traffic data.</li>
|
| 1188 |
-
</ul>
|
| 1189 |
-
<p style="color:#a89f97;font-size:11px">This platform is provided as-is for <strong
|
| 1190 |
-
style="color:#f0ece6">demonstration and evaluation purposes only</strong>.
|
| 1191 |
-
Outputs are not intended for operational, regulatory, or safety-critical use. This is an early-stage
|
| 1192 |
-
research project, not a commercial product.</p>
|
| 1193 |
-
<p style="color:#555;font-size:10px;margin-top:16px">Questions: <strong
|
| 1194 |
-
style="color:#c89a6c">support.urbanflow365@gmail.com</strong></p>
|
| 1195 |
-
</div>
|
| 1196 |
-
</div>
|
| 1197 |
-
|
| 1198 |
-
<!-- Keyboard Shortcuts Modal -->
|
| 1199 |
-
<div id="appModal-shortcutsModal" onclick="if(event.target===this)closeAppModal('shortcutsModal')"
|
| 1200 |
-
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
|
| 1201 |
-
<div
|
| 1202 |
-
style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:380px;width:100%;padding:28px;position:relative">
|
| 1203 |
-
<button onclick="closeAppModal('shortcutsModal')"
|
| 1204 |
-
style="position:absolute;top:14px;right:16px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">×</button>
|
| 1205 |
-
<h2 style="color:#f0ece6;font-size:1rem;font-weight:700;margin-bottom:4px">
|
| 1206 |
-
<i class="fa-solid fa-keyboard" style="color:#c89a6c;margin-right:6px"></i>Keyboard Shortcuts
|
| 1207 |
-
</h2>
|
| 1208 |
-
<p style="color:#555;font-size:10px;margin-bottom:16px">Navigate faster with these shortcuts</p>
|
| 1209 |
-
<div style="display:flex;flex-direction:column;gap:8px">
|
| 1210 |
-
<div
|
| 1211 |
-
style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
|
| 1212 |
-
<span style="color:#a89f97;font-size:11px;font-weight:500">About</span>
|
| 1213 |
-
<kbd
|
| 1214 |
-
style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">1</kbd>
|
| 1215 |
-
</div>
|
| 1216 |
-
<div
|
| 1217 |
-
style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
|
| 1218 |
-
<span style="color:#a89f97;font-size:11px;font-weight:500">Stats</span>
|
| 1219 |
-
<kbd
|
| 1220 |
-
style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">2</kbd>
|
| 1221 |
-
</div>
|
| 1222 |
-
<div
|
| 1223 |
-
style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
|
| 1224 |
-
<span style="color:#a89f97;font-size:11px;font-weight:500">Run</span>
|
| 1225 |
-
<kbd
|
| 1226 |
-
style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">3</kbd>
|
| 1227 |
-
</div>
|
| 1228 |
-
<div
|
| 1229 |
-
style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
|
| 1230 |
-
<span style="color:#a89f97;font-size:11px;font-weight:500">Artifacts</span>
|
| 1231 |
-
<kbd
|
| 1232 |
-
style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">4</kbd>
|
| 1233 |
-
</div>
|
| 1234 |
-
<div
|
| 1235 |
-
style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
|
| 1236 |
-
<span style="color:#a89f97;font-size:11px;font-weight:500">Settings</span>
|
| 1237 |
-
<kbd
|
| 1238 |
-
style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">5</kbd>
|
| 1239 |
</div>
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
<
|
| 1244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
</div>
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
<
|
| 1250 |
-
|
|
|
|
|
|
|
|
|
|
| 1251 |
</div>
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
</div>
|
| 1257 |
</div>
|
| 1258 |
-
<p style="color:#333;font-size:9px;margin-top:14px;text-align:center">Press <strong
|
| 1259 |
-
style="color:#555">Esc</strong> to close</p>
|
| 1260 |
</div>
|
| 1261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
|
| 1263 |
<script>
|
| 1264 |
|
| 1265 |
-
function openAppModal(id) {
|
| 1266 |
-
const el = document.getElementById('appModal-' + id);
|
| 1267 |
-
if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
|
| 1268 |
-
}
|
| 1269 |
-
function closeAppModal(id) {
|
| 1270 |
-
const el = document.getElementById('appModal-' + id);
|
| 1271 |
-
if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
|
| 1272 |
-
}
|
| 1273 |
-
|
| 1274 |
-
// Legal Menu Toggle
|
| 1275 |
-
function toggleLegalMenu(e) {
|
| 1276 |
-
if (e) e.stopPropagation();
|
| 1277 |
-
const menu = document.getElementById('legal-menu');
|
| 1278 |
-
if (menu) menu.classList.toggle('hidden');
|
| 1279 |
-
}
|
| 1280 |
|
| 1281 |
-
document.addEventListener('click', function(e) {
|
| 1282 |
-
const menu = document.getElementById('legal-menu');
|
| 1283 |
-
if (menu && !menu.classList.contains('hidden')) {
|
| 1284 |
-
if (!e.target.closest('.mobile-top-bar')) {
|
| 1285 |
-
menu.classList.add('hidden');
|
| 1286 |
-
}
|
| 1287 |
-
}
|
| 1288 |
-
});
|
| 1289 |
|
| 1290 |
-
document.addEventListener('keydown', function (e) {
|
| 1291 |
-
if (e.key === 'Escape') {
|
| 1292 |
-
closeAppModal('privacyModal');
|
| 1293 |
-
closeAppModal('termsModal');
|
| 1294 |
-
closeAppModal('shortcutsModal');
|
| 1295 |
-
const menu = document.getElementById('legal-menu');
|
| 1296 |
-
if (menu) menu.classList.add('hidden');
|
| 1297 |
-
}
|
| 1298 |
-
});
|
| 1299 |
|
| 1300 |
-
//
|
|
|
|
|
|
|
|
|
|
| 1301 |
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
| 1302 |
setTimeout(function () { openAppModal('shortcutsModal'); }, 800);
|
| 1303 |
}
|
|
@@ -1309,10 +1215,7 @@
|
|
| 1309 |
<button class="mob-nav-item" id="mob-nav-overview" onclick="switchTab('overview')">
|
| 1310 |
<i class="fa-solid fa-desktop"></i>
|
| 1311 |
</button>
|
| 1312 |
-
<button class="mob-nav-item" id="mob-nav-
|
| 1313 |
-
<i class="fa-solid fa-microchip"></i>
|
| 1314 |
-
</button>
|
| 1315 |
-
<button class="mob-nav-item" id="mob-nav-reports" onclick="switchTab('reports')">
|
| 1316 |
<i class="fa-solid fa-file-lines"></i>
|
| 1317 |
</button>
|
| 1318 |
<button class="mob-nav-item" id="mob-nav-settings" onclick="switchTab('settings')">
|
|
@@ -1324,7 +1227,12 @@
|
|
| 1324 |
<button class="mob-nav-item" id="mob-nav-feedback" onclick="switchTab('feedback')">
|
| 1325 |
<i class="fa-solid fa-comment-dots"></i>
|
| 1326 |
</button>
|
| 1327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1328 |
<script>
|
| 1329 |
if ('serviceWorker' in navigator) {
|
| 1330 |
window.addEventListener('load', () => {
|
|
|
|
| 6 |
<meta name="color-scheme" content="dark">
|
| 7 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 8 |
<title>UrbanFlow</title>
|
| 9 |
+
<link rel="icon" type="image/png" href="assets/shuriken.png">
|
| 10 |
<link rel="manifest" href="manifest.json">
|
| 11 |
<meta name="theme-color" content="#000000">
|
| 12 |
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 13 |
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
| 14 |
<meta name="apple-mobile-web-app-title" content="UrbanFlow">
|
| 15 |
+
<link rel="apple-touch-icon" href="assets/shurkien_b.png">
|
| 16 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 17 |
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 18 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
|
|
| 20 |
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
|
| 21 |
rel="stylesheet">
|
| 22 |
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 23 |
+
<link rel="stylesheet" href="css/shared.css">
|
| 24 |
<link rel="stylesheet" href="css/vehicles.css">
|
| 25 |
+
<link rel="stylesheet" href="css/auth.css">
|
| 26 |
+
<!-- SPA Guard: no active run OR page refresh → go back to landing -->
|
| 27 |
+
<script>
|
| 28 |
+
(function() {
|
| 29 |
+
// SPA: rewrite URL to root so /vehicles never shows in the address bar
|
| 30 |
+
if (window.location.pathname !== '/') {
|
| 31 |
+
history.replaceState(null, '', '/');
|
| 32 |
+
}
|
| 33 |
+
var hasRun = sessionStorage.getItem('funky_run');
|
| 34 |
+
var consumed = sessionStorage.getItem('funky_run_consumed');
|
| 35 |
+
if (!hasRun || consumed) {
|
| 36 |
+
sessionStorage.removeItem('funky_run');
|
| 37 |
+
sessionStorage.removeItem('funky_run_consumed');
|
| 38 |
+
sessionStorage.removeItem('uf_active_tab');
|
| 39 |
+
window.location.replace('/');
|
| 40 |
+
} else {
|
| 41 |
+
// Mark as consumed immediately — any refresh from here will redirect
|
| 42 |
+
sessionStorage.setItem('funky_run_consumed', '1');
|
| 43 |
+
}
|
| 44 |
+
})();
|
| 45 |
+
</script>
|
| 46 |
+
|
| 47 |
</head>
|
| 48 |
|
| 49 |
<body class="bg-black text-white h-screen w-screen flex" style="overflow:hidden">
|
|
|
|
| 54 |
|
| 55 |
<!-- Mobile Legal Overflow -->
|
| 56 |
<div class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center">
|
| 57 |
+
<button id="mobile-menu-trigger" onclick="toggleLegalMenu(event)" class="text-[#a89f97] hover:text-white p-2 transition-colors" aria-label="Menu">
|
| 58 |
+
<i id="mobile-menu-icon" class="fa-solid fa-ellipsis-vertical text-lg"></i>
|
| 59 |
</button>
|
| 60 |
<div id="legal-menu" class="hidden absolute right-0 top-full mt-2 w-48 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 overflow-hidden">
|
| 61 |
<button onclick="openAppModal('privacyModal'); toggleLegalMenu(event)" class="w-full text-left px-4 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[#a89f97] hover:text-white hover:bg-[#111] border-b border-[#1a1a1a] transition-all">
|
| 62 |
Privacy Policy
|
| 63 |
</button>
|
| 64 |
+
<button onclick="openAppModal('termsModal'); toggleLegalMenu(event)" class="w-full text-left px-4 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[#a89f97] hover:text-white hover:bg-[#111] border-b border-[#1a1a1a] transition-all">
|
| 65 |
Terms & Conditions
|
| 66 |
</button>
|
| 67 |
+
<!-- Sign Out (populated by JS) -->
|
| 68 |
+
<button id="mobile-signout-btn" class="mobile-signout-btn" style="display:none" onclick="showLogoutConfirm()">Sign Out</button>
|
| 69 |
</div>
|
| 70 |
</div>
|
| 71 |
</div>
|
|
|
|
| 82 |
</a>
|
| 83 |
<a onclick="switchTab('overview')" id="nav-overview"
|
| 84 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 85 |
+
<i class="fa-solid fa-desktop w-6"></i> <span class="font-medium">Overview</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</a>
|
| 87 |
+
<a onclick="switchTab('results')" id="nav-results"
|
| 88 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 89 |
+
<i class="fa-solid fa-file-lines w-6"></i> <span class="font-medium">Results</span>
|
| 90 |
</a>
|
| 91 |
<a onclick="switchTab('settings')" id="nav-settings"
|
| 92 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
|
|
|
| 100 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 101 |
<i class="fa-solid fa-comment-dots w-6"></i> <span class="font-medium">Feedback</span>
|
| 102 |
</a>
|
| 103 |
+
<a onclick="switchTab('profile')" id="nav-profile"
|
| 104 |
+
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 105 |
+
<div id="sidebar-profile-pfp-wrap" class="w-6 h-6 inline-flex items-center justify-center flex-shrink-0"><i class="fa-solid fa-circle-user"></i></div>
|
| 106 |
+
<span class="font-medium ml-3">Profile</span>
|
| 107 |
+
</a>
|
| 108 |
</nav>
|
| 109 |
<div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
|
| 110 |
style="border-color:#2a2a2a">
|
|
|
|
| 116 |
class="text-[10px] font-bold uppercase tracking-widest transition w-full text-center py-1 rounded"
|
| 117 |
style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
|
| 118 |
onmouseout="this.style.color='#a89f97'">Terms & Conditions</button>
|
| 119 |
+
<p class="text-[11px] font-medium mt-2 mb-1 text-center" style="color:#555">© 2026 UrbanFlow<br><span class="text-[9px] text-[#444] block mt-1">All rights reserved.</span></p>
|
| 120 |
</div>
|
| 121 |
</aside>
|
| 122 |
|
| 123 |
<!-- Mobile Navigation (hidden on desktop) -->
|
| 124 |
<div
|
| 125 |
+
class="mobile-nav hidden fixed top-0 left-0 right-0 z-30 bg-black border-b border-slate-800 px-4 py-2 items-center justify-between">
|
| 126 |
<img src="assets/uf_rf.png" alt="UF" class="h-8">
|
| 127 |
+
<div class="flex items-center gap-4">
|
| 128 |
+
<div class="dropdown relative">
|
| 129 |
+
<button onclick="toggleLegalMenu(event)" class="text-slate-500 hover:text-white transition">
|
| 130 |
+
<i class="fa-solid fa-ellipsis-vertical"></i>
|
| 131 |
+
</button>
|
| 132 |
+
<div id="legal-menu" class="hidden absolute right-0 mt-2 w-48 bg-neutral-900 border border-neutral-800 rounded-lg shadow-xl py-2 z-50">
|
| 133 |
+
<a onclick="openAppModal('privacyModal')" class="block px-4 py-2 text-xs text-slate-300 hover:bg-neutral-800 hover:text-white cursor-pointer">Privacy Policy</a>
|
| 134 |
+
<a onclick="openAppModal('termsModal')" class="block px-4 py-2 text-xs text-slate-300 hover:bg-neutral-800 hover:text-white cursor-pointer">Terms & Conditions</a>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
|
|
|
|
| 142 |
|
| 143 |
<main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
|
| 144 |
|
| 145 |
+
<!-- Progress Bar (shared) -->
|
| 146 |
+
<div id="progress-bar-wrapper"
|
| 147 |
+
class="w-full bg-neutral-950 rounded-xl px-6 py-4 border border-neutral-800 shadow-sm flex items-center justify-between flex-shrink-0">
|
| 148 |
+
<div class="flex items-center space-x-4 flex-1 mr-6">
|
| 149 |
+
<span class="text-[11px] font-black text-white uppercase tracking-wider whitespace-nowrap"
|
| 150 |
+
id="proc-label">Waiting</span>
|
| 151 |
+
<div class="flex-1 h-2 bg-[#111111] rounded-full overflow-hidden relative border border-[#1a1a1a]">
|
| 152 |
+
<div id="proc-bar" class="h-full bg-[#444444] rounded-full transition-all duration-500 ease-out"
|
| 153 |
+
style="width: 0%"></div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="flex items-center space-x-6 text-xs font-bold text-white whitespace-nowrap">
|
| 157 |
+
<span id="proc-frames">Awaiting Input</span>
|
| 158 |
+
<span id="proc-pct">Idle</span>
|
| 159 |
+
<div class="uf-select-wrap" id="live-palette-wrap">
|
| 160 |
+
<div class="uf-select-trigger" id="live-palette-trigger" onclick="ufSelectToggle('live-palette')">
|
| 161 |
+
<span class="uf-select-label" id="live-palette-label">Default</span>
|
| 162 |
+
<i class="fa-solid fa-chevron-down uf-select-arrow" id="live-palette-arrow"></i>
|
| 163 |
+
</div>
|
| 164 |
+
<div class="uf-select-dropdown hidden" id="live-palette-dropdown">
|
| 165 |
+
<div class="uf-select-option" data-value="default" onclick="ufSelectPick('live-palette','default','Default')">Default</div>
|
| 166 |
+
<div class="uf-select-option" data-value="vibrant" onclick="ufSelectPick('live-palette','vibrant','Vibrant')">Vibrant</div>
|
| 167 |
+
<div class="uf-select-option" data-value="corporate" onclick="ufSelectPick('live-palette','corporate','Corporate')">Corporate</div>
|
| 168 |
+
<div class="uf-select-option" data-value="neon" onclick="ufSelectPick('live-palette','neon','Neon Night')">Neon Night</div>
|
| 169 |
+
<div class="uf-select-option" data-value="earth" onclick="ufSelectPick('live-palette','earth','Earth Tones')">Earth Tones</div>
|
| 170 |
+
<div class="uf-select-option" data-value="ocean" onclick="ufSelectPick('live-palette','ocean','Ocean Breeze')">Ocean Breeze</div>
|
| 171 |
+
<div class="uf-select-option" data-value="sunset" onclick="ufSelectPick('live-palette','sunset','Sunset Glow')">Sunset Glow</div>
|
| 172 |
+
<div class="uf-select-option" data-value="midnight" onclick="ufSelectPick('live-palette','midnight','Midnight Deep')">Midnight Deep</div>
|
| 173 |
+
<div class="uf-select-option" data-value="gold" onclick="ufSelectPick('live-palette','gold','Monochrome Gold')">Monochrome Gold</div>
|
| 174 |
+
</div>
|
| 175 |
+
<input type="hidden" id="live-palette-select" value="default">
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
<!-- TAB: About -->
|
| 181 |
<div id="tab-about" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 182 |
<div class="bg-black border rounded-xl p-12 shadow-2xl space-y-8 flex flex-col justify-center"
|
|
|
|
| 214 |
</p>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-12 pt-8 text-left border-t max-w-6xl mx-auto"
|
| 218 |
style="border-color:#1a1a1a">
|
| 219 |
<div class="space-y-4">
|
| 220 |
<h4 class="font-bold text-[13px] uppercase tracking-wider" style="color:#f0ece6">What You Will Get Here</h4>
|
|
|
|
| 229 |
</li>
|
| 230 |
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 231 |
style="color:#c89a6c"></i>
|
| 232 |
+
<span>Congestion Analytics, Peak Detection, and temporal Trends</span>
|
| 233 |
</li>
|
| 234 |
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 235 |
style="color:#c89a6c"></i>
|
|
|
|
| 243 |
</h4>
|
| 244 |
<p class="text-xs leading-relaxed" style="color:#a89f97">
|
| 245 |
UrbanFlow delivers an estimated accuracy of ±5–8% on dense mixed-traffic footage. Results
|
| 246 |
+
may vary slightly across runs due to frame-by-frame inference variability.
|
| 247 |
</p>
|
| 248 |
<p class="text-xs leading-relaxed" style="color:#a89f97">
|
| 249 |
For research or planning use, process the same footage 2–3 times
|
| 250 |
and average the results for improved reliability.
|
| 251 |
</p>
|
| 252 |
</div>
|
| 253 |
+
<div class="space-y-4">
|
| 254 |
+
<h4 class="font-bold text-[13px] uppercase tracking-wider" style="color:#f0ece6">What's Next (Roadmap)</h4>
|
| 255 |
+
<ul class="text-xs space-y-3 pl-1">
|
| 256 |
+
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 257 |
+
style="color:#c89a6c"></i>
|
| 258 |
+
<span><strong>RTSP Streaming Integration:</strong> Live CCTV stream processing directly from your network.</span>
|
| 259 |
+
</li>
|
| 260 |
+
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 261 |
+
style="color:#c89a6c"></i>
|
| 262 |
+
<span><strong>ANPR / ALPR:</strong> Automated Number Plate Recognition for localized enforcement.</span>
|
| 263 |
+
</li>
|
| 264 |
+
<li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
|
| 265 |
+
style="color:#c89a6c"></i>
|
| 266 |
+
<span><strong>Helmet Detection:</strong> Real-time compliance tracking for two-wheelers.</span>
|
| 267 |
+
</li>
|
| 268 |
+
</ul>
|
| 269 |
+
</div>
|
| 270 |
</div>
|
| 271 |
|
| 272 |
<div class="text-center pt-6 border-t" style="border-color:#1a1a1a">
|
|
|
|
| 285 |
</div>
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
<div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden"
|
| 289 |
style="position:relative">
|
| 290 |
|
|
|
|
| 376 |
<div class="flex-1 w-full relative min-h-[220px]">
|
| 377 |
<canvas id="flowChart"></canvas>
|
| 378 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
</div>
|
| 380 |
</div>
|
| 381 |
|
| 382 |
+
<!-- TAB: Results (Merged Artifacts + Run) -->
|
| 383 |
+
<div id="tab-results" class="hidden flex-1 min-h-0 overflow-y-auto">
|
|
|
|
|
|
|
| 384 |
<div id="reports-pending-message"
|
| 385 |
class="mb-4 text-center p-8 flex flex-col items-center justify-center gap-4"
|
| 386 |
style="min-height: 60vh;">
|
| 387 |
<i class="fa-solid fa-hourglass-half text-5xl mb-2" style="color:#3a3230;"></i>
|
| 388 |
<div style="color:#c89a6c; font-size:13px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase;">
|
| 389 |
+
Results Pending
|
| 390 |
</div>
|
| 391 |
<span id="reports-pending-text"
|
| 392 |
class="text-xs font-medium tracking-wide uppercase leading-relaxed text-center"
|
|
|
|
| 395 |
</span>
|
| 396 |
</div>
|
| 397 |
|
| 398 |
+
<div id="results-content-wrap" class="hidden space-y-8 w-full pb-6">
|
| 399 |
+
|
| 400 |
+
<!-- Download Button -->
|
| 401 |
+
<div class="flex justify-center lg:hidden">
|
| 402 |
+
<button onclick="downloadArtifacts()" id="btn-download-bundle" class="w-fit px-8 py-3 font-bold text-xs rounded-full transition flex items-center justify-center gap-2 shadow-lg active:scale-95 hover:scale-105"
|
| 403 |
+
style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
|
| 404 |
+
<i class="fa-solid fa-download"></i> Download All Artifacts
|
| 405 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
</div>
|
|
|
|
| 407 |
|
| 408 |
+
<!-- Section: Performance Insights (formerly Run tab) -->
|
| 409 |
+
<div class="space-y-6">
|
| 410 |
+
<!-- PCU + Report Grid (unified) -->
|
| 411 |
+
<div id="post-process-cards" class="mb-4">
|
| 412 |
+
<div class="bg-black rounded-xl p-6 border border-neutral-800 shadow-sm flex flex-col min-h-[140px]">
|
| 413 |
+
<div class="flex justify-between items-center mb-4 relative">
|
| 414 |
+
<h3 class="font-bold text-white text-sm flex items-center">PCU Analysis
|
| 415 |
+
<span class="info-wrap"><span class="info-btn" style="background:#222;color:#888"><i
|
| 416 |
+
class="fa-solid fa-info"></i></span>
|
| 417 |
+
<span class="info-tip">Passenger Car Units (IRC:106-1990). Converts heterogeneous Indian
|
| 418 |
+
traffic into standardized units for road capacity analysis.</span></span>
|
| 419 |
+
</h3>
|
| 420 |
+
</div>
|
| 421 |
+
<div class="flex-1 flex items-center justify-center" id="pcu-stats-card">
|
| 422 |
+
<div class="text-center text-slate-600 text-xs">Available after processing</div>
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
<!-- Report Grid -->
|
| 428 |
+
<div id="reports-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-4"></div>
|
| 429 |
|
| 430 |
+
<!-- Collapsible Technical Telemetry (formerly Run tab) -->
|
| 431 |
+
<div class="mt-12 pt-12 border-t border-neutral-900">
|
| 432 |
+
<details class="group">
|
| 433 |
+
<summary class="flex items-center justify-between cursor-pointer list-none text-slate-500 hover:text-slate-300 transition">
|
| 434 |
+
<div class="flex items-center gap-3">
|
| 435 |
+
<i class="fa-solid fa-microchip text-xs"></i>
|
| 436 |
+
<span class="text-sm font-bold uppercase tracking-widest">Technical Runtime Telemetry</span>
|
| 437 |
+
</div>
|
| 438 |
+
<i class="fa-solid fa-chevron-down text-[10px] group-open:rotate-180 transition-transform"></i>
|
| 439 |
+
</summary>
|
| 440 |
+
|
| 441 |
+
<div class="mt-8 space-y-8 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 442 |
+
<!-- Process Analytics -->
|
| 443 |
+
<div id="run-results-card" class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm overflow-hidden flex flex-col">
|
| 444 |
+
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 445 |
+
<h3 class="font-bold text-sm text-white">Execution Telemetry</h3>
|
| 446 |
+
</div>
|
| 447 |
+
<div class="p-8">
|
| 448 |
+
<div id="run-results-content" class="grid grid-cols-3 gap-8"></div>
|
| 449 |
+
</div>
|
| 450 |
+
</div>
|
| 451 |
+
|
| 452 |
+
<!-- Technical Context Row -->
|
| 453 |
+
<div class="grid grid-cols-2 gap-6">
|
| 454 |
+
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
|
| 455 |
+
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 456 |
+
<h3 class="font-bold text-sm text-white">Stream Source Profile</h3>
|
| 457 |
+
</div>
|
| 458 |
+
<div class="p-6 space-y-4 text-xs text-slate-400" id="panel-video"></div>
|
| 459 |
+
</div>
|
| 460 |
+
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
|
| 461 |
+
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 462 |
+
<h3 class="font-bold text-sm text-white">System Resource Utilization</h3>
|
| 463 |
+
</div>
|
| 464 |
+
<div class="p-6 space-y-4 text-xs text-slate-400" id="panel-perf"></div>
|
| 465 |
+
</div>
|
| 466 |
+
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
|
| 467 |
+
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 468 |
+
<h3 class="font-bold text-sm text-white">Model Architecture & Logic</h3>
|
| 469 |
+
</div>
|
| 470 |
+
<div class="p-6 space-y-4 text-xs text-slate-400" id="panel-model"></div>
|
| 471 |
+
</div>
|
| 472 |
+
<div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
|
| 473 |
+
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 474 |
+
<h3 class="font-bold text-sm text-white">Inference Parameters</h3>
|
| 475 |
+
</div>
|
| 476 |
+
<div class="p-6 space-y-4 text-xs text-slate-400" id="panel-infer"></div>
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
</details>
|
| 481 |
+
</div>
|
| 482 |
+
</div>
|
| 483 |
</div>
|
| 484 |
</div>
|
| 485 |
|
|
|
|
| 487 |
<div id="tab-settings" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 488 |
<div class="grid grid-cols-2 gap-6 w-full">
|
| 489 |
<!-- Processing Parameters -->
|
| 490 |
+
<div class="col-span-1 bg-black rounded-xl border shadow-sm overflow-hidden flex flex-col self-start"
|
| 491 |
style="border-color:#2a2a2a">
|
| 492 |
<div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
|
| 493 |
<h3 class="font-bold text-white text-sm flex items-center">Inference Configuration Profile
|
|
|
|
| 501 |
<p class="text-[10px] mt-0.1 uppercase tracking-widest font-medium" style="color:#a89f97">
|
| 502 |
Auto-configured pipeline parameters</p>
|
| 503 |
</div>
|
| 504 |
+
<div class="px-6 py-2" id="settings-params">
|
| 505 |
<div class="s-row" data-param="imgsz">
|
| 506 |
<div>
|
| 507 |
<div class="text-xs font-semibold text-slate-300 flex items-center">Image Size
|
|
|
|
| 567 |
onclick="stepParam('stride',1)">›</button></div>
|
| 568 |
</div>
|
| 569 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
</div>
|
| 571 |
</div>
|
| 572 |
|
|
|
|
| 669 |
<div class="toggle-thumb"></div>
|
| 670 |
</div>
|
| 671 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
</div>
|
| 673 |
</div>
|
| 674 |
|
|
|
|
| 681 |
</button>
|
| 682 |
</div>
|
| 683 |
|
| 684 |
+
<!-- Home Button (visible only after processing completes) -->
|
| 685 |
<div class="col-span-3 pb-4 hidden flex justify-center" id="new-analysis-wrap">
|
| 686 |
<button onclick="startNewAnalysis()"
|
| 687 |
class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg hover:scale-105 active:scale-95"
|
| 688 |
+
style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
|
| 689 |
+
<span><i class="fa-solid fa-house mr-2 text-sm"></i> Home</span>
|
| 690 |
</button>
|
| 691 |
</div>
|
| 692 |
</div>
|
|
|
|
| 755 |
</button>
|
| 756 |
<div id="h3-ans" class="hidden px-6 pb-6 text-xs leading-relaxed"
|
| 757 |
style="color:#777">
|
| 758 |
+
To analyze a new video, go to 'Settings' and click 'Home'.
|
| 759 |
+
This resets your current session and returns you
|
| 760 |
to the Home screen.
|
| 761 |
</div>
|
| 762 |
</div>
|
|
|
|
| 1130 |
</div>
|
| 1131 |
</div>
|
| 1132 |
|
| 1133 |
+
<!-- TAB: Profile -->
|
| 1134 |
+
<div id="tab-profile" class="hidden flex-1 min-h-0 overflow-y-auto">
|
| 1135 |
+
<!-- Single unified card — full width -->
|
| 1136 |
+
<div class="bg-neutral-950 rounded-2xl border border-neutral-900 overflow-hidden">
|
| 1137 |
+
<!-- Identity Section -->
|
| 1138 |
+
<div class="p-6 lg:p-8 flex flex-col items-center lg:flex-row lg:items-start gap-6 border-b border-neutral-900">
|
| 1139 |
+
<div class="relative group flex-shrink-0">
|
| 1140 |
+
<img id="profile-pfp-large" src="" alt="" class="w-20 h-20 lg:w-24 lg:h-24 rounded-full border-2 border-neutral-800 shadow-2xl object-cover">
|
| 1141 |
+
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-[10px] font-bold text-white uppercase text-center p-2">Managed by Google</div>
|
| 1142 |
+
</div>
|
| 1143 |
+
<div class="flex-1 w-full text-center lg:text-left space-y-4">
|
| 1144 |
+
<div>
|
| 1145 |
+
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-500 block mb-1">Email Address</label>
|
| 1146 |
+
<div id="profile-email" class="text-sm font-medium text-slate-300"></div>
|
| 1147 |
+
</div>
|
| 1148 |
+
<div>
|
| 1149 |
+
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-500 block mb-1">Display Name</label>
|
| 1150 |
+
<div class="flex items-center gap-3 justify-center lg:justify-start">
|
| 1151 |
+
<input type="text" id="profile-username-input" class="bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-neutral-600 transition w-full max-w-[240px]">
|
| 1152 |
+
<button onclick="saveProfileUsername()" id="btn-save-username" class="px-4 py-2 bg-neutral-800 hover:bg-neutral-700 text-[10px] font-bold uppercase tracking-widest rounded-lg text-slate-300 transition">Save</button>
|
| 1153 |
+
</div>
|
| 1154 |
+
<p class="text-[9px] text-slate-600 mt-1">This name will be used on exported reports.</p>
|
| 1155 |
+
</div>
|
| 1156 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1157 |
</div>
|
| 1158 |
+
|
| 1159 |
+
<!-- Stats Row -->
|
| 1160 |
+
<div class="grid grid-cols-2 gap-0 border-b border-neutral-900">
|
| 1161 |
+
<div class="p-5 lg:p-6 border-r border-neutral-900">
|
| 1162 |
+
<div class="text-[9px] font-bold uppercase tracking-widest text-slate-600 mb-1">Total Runs</div>
|
| 1163 |
+
<div id="profile-total-runs" class="text-2xl font-bold text-white">0</div>
|
| 1164 |
+
</div>
|
| 1165 |
+
<div class="p-5 lg:p-6">
|
| 1166 |
+
<div class="text-[9px] font-bold uppercase tracking-widest text-slate-600 mb-1">Last Active</div>
|
| 1167 |
+
<div id="profile-last-active" class="text-sm font-bold text-slate-300">Never</div>
|
| 1168 |
+
</div>
|
| 1169 |
</div>
|
| 1170 |
+
|
| 1171 |
+
<!-- Terms Acceptance Indicator -->
|
| 1172 |
+
<div class="p-5 lg:p-6 flex items-center gap-3 border-b border-neutral-900">
|
| 1173 |
+
<i class="fa-solid fa-circle-check text-emerald-500"></i>
|
| 1174 |
+
<div>
|
| 1175 |
+
<div class="text-xs font-bold text-slate-300">Terms & Privacy Accepted</div>
|
| 1176 |
+
<div class="text-[9px] text-slate-600">You have agreed to the Privacy Policy and Terms & Conditions.</div>
|
| 1177 |
+
</div>
|
| 1178 |
</div>
|
| 1179 |
+
|
| 1180 |
+
<!-- Sign Out -->
|
| 1181 |
+
<div class="p-5 lg:p-6 flex items-center justify-between">
|
| 1182 |
+
<div>
|
| 1183 |
+
<h3 class="text-xs font-bold uppercase tracking-widest text-red-400">Account Access</h3>
|
| 1184 |
+
<p class="text-[10px] text-slate-500 mt-1">Sign out clears your local session.</p>
|
| 1185 |
+
</div>
|
| 1186 |
+
<button onclick="showLogoutConfirm()" class="px-6 py-2.5 bg-red-900/20 hover:bg-red-900/30 text-red-400 border border-red-900/30 text-[10px] font-bold uppercase tracking-widest rounded-xl transition">Sign Out</button>
|
| 1187 |
</div>
|
| 1188 |
</div>
|
|
|
|
|
|
|
| 1189 |
</div>
|
| 1190 |
+
|
| 1191 |
+
</main>
|
| 1192 |
+
|
| 1193 |
+
<script src="js/templates.js"></script>
|
| 1194 |
+
<script src="js/shared.js"></script>
|
| 1195 |
+
<script src="js/auth.js"></script>
|
| 1196 |
+
<script src="js/vehicles.js"></script>
|
| 1197 |
|
| 1198 |
<script>
|
| 1199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1202 |
|
| 1203 |
+
// Inject shared components (modals, shortcuts, nav)
|
| 1204 |
+
injectLegalModals();
|
| 1205 |
+
injectShortcutsModal();
|
| 1206 |
+
// Auto-show keyboard shortcuts on first desktop visit
|
| 1207 |
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
| 1208 |
setTimeout(function () { openAppModal('shortcutsModal'); }, 800);
|
| 1209 |
}
|
|
|
|
| 1215 |
<button class="mob-nav-item" id="mob-nav-overview" onclick="switchTab('overview')">
|
| 1216 |
<i class="fa-solid fa-desktop"></i>
|
| 1217 |
</button>
|
| 1218 |
+
<button class="mob-nav-item" id="mob-nav-results" onclick="switchTab('results')">
|
|
|
|
|
|
|
|
|
|
| 1219 |
<i class="fa-solid fa-file-lines"></i>
|
| 1220 |
</button>
|
| 1221 |
<button class="mob-nav-item" id="mob-nav-settings" onclick="switchTab('settings')">
|
|
|
|
| 1227 |
<button class="mob-nav-item" id="mob-nav-feedback" onclick="switchTab('feedback')">
|
| 1228 |
<i class="fa-solid fa-comment-dots"></i>
|
| 1229 |
</button>
|
| 1230 |
+
<button class="mob-nav-item" id="mob-nav-profile" onclick="switchTab('profile')">
|
| 1231 |
+
<div id="mob-pfp-wrap" class="w-7 h-7 rounded-full overflow-hidden bg-neutral-800 flex items-center justify-center mx-auto">
|
| 1232 |
+
<i class="fa-solid fa-circle-user"></i>
|
| 1233 |
+
</div>
|
| 1234 |
+
</button>
|
| 1235 |
+
</nav>
|
| 1236 |
<script>
|
| 1237 |
if ('serviceWorker' in navigator) {
|
| 1238 |
window.addEventListener('load', () => {
|
requirements.txt
CHANGED
|
@@ -10,6 +10,7 @@ websockets==12.0
|
|
| 10 |
openvino>=2024.0.0
|
| 11 |
lap>=0.5.12
|
| 12 |
resend
|
|
|
|
| 13 |
torch==2.1.0+cpu
|
| 14 |
torchvision==0.16.0+cpu
|
| 15 |
--extra-index-url https://download.pytorch.org/whl/cpu
|
|
|
|
| 10 |
openvino>=2024.0.0
|
| 11 |
lap>=0.5.12
|
| 12 |
resend
|
| 13 |
+
google-auth>=2.20.0
|
| 14 |
torch==2.1.0+cpu
|
| 15 |
torchvision==0.16.0+cpu
|
| 16 |
--extra-index-url https://download.pytorch.org/whl/cpu
|