Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- =2.0.0 +2 -0
- requirements.txt +1 -0
- web_demo/app.py +154 -1
- web_demo/static/app.js +4 -0
- web_demo/supabase_client.py +126 -0
- web_demo/templates/admin.html +215 -0
- web_demo/templates/index.html +4 -0
=2.0.0
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
WARNING: You are using pip version 21.2.4; however, version 26.0.1 is available.
|
| 2 |
+
You should consider upgrading via the '/Users/fengxie/Build/ring-size-cv/.venv/bin/python3 -m pip install --upgrade pip' command.
|
requirements.txt
CHANGED
|
@@ -6,3 +6,4 @@ scikit-learn>=1.3.0
|
|
| 6 |
flask>=3.0.0
|
| 7 |
gunicorn>=21.2.0
|
| 8 |
openai>=1.0.0
|
|
|
|
|
|
| 6 |
flask>=3.0.0
|
| 7 |
gunicorn>=21.2.0
|
| 8 |
openai>=1.0.0
|
| 9 |
+
supabase>=2.0.0
|
web_demo/app.py
CHANGED
|
@@ -6,6 +6,8 @@ Upload an image, run measurement, and return JSON + debug overlay.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
|
|
|
| 9 |
import json
|
| 10 |
import sys
|
| 11 |
import uuid
|
|
@@ -14,7 +16,7 @@ from typing import Dict, Any
|
|
| 14 |
|
| 15 |
import cv2
|
| 16 |
import numpy as np
|
| 17 |
-
from flask import Flask, jsonify, render_template, request, send_from_directory
|
| 18 |
from werkzeug.utils import secure_filename
|
| 19 |
|
| 20 |
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
@@ -23,6 +25,7 @@ sys.path.insert(0, str(ROOT_DIR))
|
|
| 23 |
from measure_finger import measure_finger, measure_multi_finger, apply_calibration
|
| 24 |
from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
|
| 25 |
from src.ai_recommendation import ai_explain_recommendation
|
|
|
|
| 26 |
|
| 27 |
APP_ROOT = Path(__file__).resolve().parent
|
| 28 |
UPLOAD_DIR = APP_ROOT / "uploads"
|
|
@@ -109,6 +112,7 @@ def api_measure():
|
|
| 109 |
|
| 110 |
finger_index = request.form.get("finger_index", "index")
|
| 111 |
mode = request.form.get("mode", "single")
|
|
|
|
| 112 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 113 |
if ring_model not in VALID_RING_MODELS:
|
| 114 |
ring_model = DEFAULT_RING_MODEL
|
|
@@ -128,6 +132,9 @@ def api_measure():
|
|
| 128 |
image=image,
|
| 129 |
input_image_url=f"/uploads/{upload_name}",
|
| 130 |
ring_model=ring_model,
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
|
| 133 |
return _run_measurement(
|
|
@@ -135,6 +142,9 @@ def api_measure():
|
|
| 135 |
finger_index=finger_index,
|
| 136 |
input_image_url=f"/uploads/{upload_name}",
|
| 137 |
ring_model=ring_model,
|
|
|
|
|
|
|
|
|
|
| 138 |
)
|
| 139 |
|
| 140 |
|
|
@@ -142,6 +152,7 @@ def api_measure():
|
|
| 142 |
def api_measure_default():
|
| 143 |
finger_index = request.form.get("finger_index", "index")
|
| 144 |
mode = request.form.get("mode", "single")
|
|
|
|
| 145 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 146 |
if ring_model not in VALID_RING_MODELS:
|
| 147 |
ring_model = DEFAULT_RING_MODEL
|
|
@@ -157,6 +168,7 @@ def api_measure_default():
|
|
| 157 |
image=image,
|
| 158 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 159 |
ring_model=ring_model,
|
|
|
|
| 160 |
)
|
| 161 |
|
| 162 |
return _run_measurement(
|
|
@@ -164,6 +176,7 @@ def api_measure_default():
|
|
| 164 |
finger_index=finger_index,
|
| 165 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 166 |
ring_model=ring_model,
|
|
|
|
| 167 |
)
|
| 168 |
|
| 169 |
|
|
@@ -172,6 +185,9 @@ def _run_measurement(
|
|
| 172 |
finger_index: str,
|
| 173 |
input_image_url: str,
|
| 174 |
ring_model: str = DEFAULT_RING_MODEL,
|
|
|
|
|
|
|
|
|
|
| 175 |
):
|
| 176 |
run_id = uuid.uuid4().hex[:12]
|
| 177 |
|
|
@@ -204,12 +220,39 @@ def _run_measurement(
|
|
| 204 |
result_json_path = RESULTS_DIR / result_json_name
|
| 205 |
_save_json(result_json_path, result)
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
payload = {
|
| 208 |
"success": result.get("fail_reason") is None,
|
| 209 |
"result": result,
|
| 210 |
"result_image_url": f"/results/{result_png_name}",
|
| 211 |
"input_image_url": input_image_url,
|
| 212 |
"result_json_url": f"/results/{result_json_name}",
|
|
|
|
| 213 |
}
|
| 214 |
|
| 215 |
return jsonify(payload)
|
|
@@ -219,6 +262,9 @@ def _run_multi_measurement(
|
|
| 219 |
image,
|
| 220 |
input_image_url: str,
|
| 221 |
ring_model: str = DEFAULT_RING_MODEL,
|
|
|
|
|
|
|
|
|
|
| 222 |
):
|
| 223 |
"""Run multi-finger measurement pipeline."""
|
| 224 |
run_id = uuid.uuid4().hex[:12]
|
|
@@ -264,6 +310,37 @@ def _run_multi_measurement(
|
|
| 264 |
result_json_path = RESULTS_DIR / result_json_name
|
| 265 |
_save_json(result_json_path, result)
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
payload = {
|
| 268 |
"success": result.get("fail_reason") is None,
|
| 269 |
"mode": "multi",
|
|
@@ -271,10 +348,86 @@ def _run_multi_measurement(
|
|
| 271 |
"result_image_url": f"/results/{result_png_name}",
|
| 272 |
"input_image_url": input_image_url,
|
| 273 |
"result_json_url": f"/results/{result_json_name}",
|
|
|
|
| 274 |
}
|
| 275 |
|
| 276 |
return jsonify(payload)
|
| 277 |
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
if __name__ == "__main__":
|
| 280 |
app.run(host="0.0.0.0", port=8000, debug=True)
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import csv
|
| 10 |
+
import io
|
| 11 |
import json
|
| 12 |
import sys
|
| 13 |
import uuid
|
|
|
|
| 16 |
|
| 17 |
import cv2
|
| 18 |
import numpy as np
|
| 19 |
+
from flask import Flask, Response, jsonify, render_template, request, send_from_directory
|
| 20 |
from werkzeug.utils import secure_filename
|
| 21 |
|
| 22 |
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
| 25 |
from measure_finger import measure_finger, measure_multi_finger, apply_calibration
|
| 26 |
from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
|
| 27 |
from src.ai_recommendation import ai_explain_recommendation
|
| 28 |
+
from web_demo.supabase_client import upload_file, save_measurement, list_measurements, update_ground_truth
|
| 29 |
|
| 30 |
APP_ROOT = Path(__file__).resolve().parent
|
| 31 |
UPLOAD_DIR = APP_ROOT / "uploads"
|
|
|
|
| 112 |
|
| 113 |
finger_index = request.form.get("finger_index", "index")
|
| 114 |
mode = request.form.get("mode", "single")
|
| 115 |
+
kol_name = request.form.get("kol_name", "").strip()
|
| 116 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 117 |
if ring_model not in VALID_RING_MODELS:
|
| 118 |
ring_model = DEFAULT_RING_MODEL
|
|
|
|
| 132 |
image=image,
|
| 133 |
input_image_url=f"/uploads/{upload_name}",
|
| 134 |
ring_model=ring_model,
|
| 135 |
+
kol_name=kol_name,
|
| 136 |
+
upload_path=upload_path,
|
| 137 |
+
upload_name=upload_name,
|
| 138 |
)
|
| 139 |
|
| 140 |
return _run_measurement(
|
|
|
|
| 142 |
finger_index=finger_index,
|
| 143 |
input_image_url=f"/uploads/{upload_name}",
|
| 144 |
ring_model=ring_model,
|
| 145 |
+
kol_name=kol_name,
|
| 146 |
+
upload_path=upload_path,
|
| 147 |
+
upload_name=upload_name,
|
| 148 |
)
|
| 149 |
|
| 150 |
|
|
|
|
| 152 |
def api_measure_default():
|
| 153 |
finger_index = request.form.get("finger_index", "index")
|
| 154 |
mode = request.form.get("mode", "single")
|
| 155 |
+
kol_name = request.form.get("kol_name", "").strip()
|
| 156 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 157 |
if ring_model not in VALID_RING_MODELS:
|
| 158 |
ring_model = DEFAULT_RING_MODEL
|
|
|
|
| 168 |
image=image,
|
| 169 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 170 |
ring_model=ring_model,
|
| 171 |
+
kol_name=kol_name,
|
| 172 |
)
|
| 173 |
|
| 174 |
return _run_measurement(
|
|
|
|
| 176 |
finger_index=finger_index,
|
| 177 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 178 |
ring_model=ring_model,
|
| 179 |
+
kol_name=kol_name,
|
| 180 |
)
|
| 181 |
|
| 182 |
|
|
|
|
| 185 |
finger_index: str,
|
| 186 |
input_image_url: str,
|
| 187 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 188 |
+
kol_name: str = "",
|
| 189 |
+
upload_path: Path = None,
|
| 190 |
+
upload_name: str = "",
|
| 191 |
):
|
| 192 |
run_id = uuid.uuid4().hex[:12]
|
| 193 |
|
|
|
|
| 220 |
result_json_path = RESULTS_DIR / result_json_name
|
| 221 |
_save_json(result_json_path, result)
|
| 222 |
|
| 223 |
+
# --- Persist to Supabase ---
|
| 224 |
+
photo_public_url = None
|
| 225 |
+
result_public_url = None
|
| 226 |
+
if upload_path and upload_path.exists():
|
| 227 |
+
photo_public_url = upload_file(str(upload_path), f"photos/{upload_name}")
|
| 228 |
+
if result_png_path.exists():
|
| 229 |
+
result_public_url = upload_file(str(result_png_path), f"results/{result_png_name}")
|
| 230 |
+
|
| 231 |
+
ring_size = result.get("ring_size", {})
|
| 232 |
+
measurement_id = save_measurement({
|
| 233 |
+
"run_id": run_id,
|
| 234 |
+
"kol_name": kol_name,
|
| 235 |
+
"mode": "single",
|
| 236 |
+
"ring_model": ring_model,
|
| 237 |
+
"finger_index": finger_index,
|
| 238 |
+
"diameter_cm": result.get("finger_outer_diameter_cm"),
|
| 239 |
+
"confidence": result.get("confidence"),
|
| 240 |
+
"overall_best_size": ring_size.get("best_match"),
|
| 241 |
+
"overall_range_min": ring_size.get("range_min"),
|
| 242 |
+
"overall_range_max": ring_size.get("range_max"),
|
| 243 |
+
"photo_url": photo_public_url,
|
| 244 |
+
"result_url": result_public_url,
|
| 245 |
+
"result_json": result,
|
| 246 |
+
"fail_reason": result.get("fail_reason"),
|
| 247 |
+
})
|
| 248 |
+
|
| 249 |
payload = {
|
| 250 |
"success": result.get("fail_reason") is None,
|
| 251 |
"result": result,
|
| 252 |
"result_image_url": f"/results/{result_png_name}",
|
| 253 |
"input_image_url": input_image_url,
|
| 254 |
"result_json_url": f"/results/{result_json_name}",
|
| 255 |
+
"measurement_id": measurement_id,
|
| 256 |
}
|
| 257 |
|
| 258 |
return jsonify(payload)
|
|
|
|
| 262 |
image,
|
| 263 |
input_image_url: str,
|
| 264 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 265 |
+
kol_name: str = "",
|
| 266 |
+
upload_path: Path = None,
|
| 267 |
+
upload_name: str = "",
|
| 268 |
):
|
| 269 |
"""Run multi-finger measurement pipeline."""
|
| 270 |
run_id = uuid.uuid4().hex[:12]
|
|
|
|
| 310 |
result_json_path = RESULTS_DIR / result_json_name
|
| 311 |
_save_json(result_json_path, result)
|
| 312 |
|
| 313 |
+
# --- Persist to Supabase ---
|
| 314 |
+
photo_public_url = None
|
| 315 |
+
result_public_url = None
|
| 316 |
+
if upload_path and upload_path.exists():
|
| 317 |
+
photo_public_url = upload_file(str(upload_path), f"photos/{upload_name}")
|
| 318 |
+
if result_png_path.exists():
|
| 319 |
+
result_public_url = upload_file(str(result_png_path), f"results/{result_png_name}")
|
| 320 |
+
|
| 321 |
+
# Compute overall confidence from per-finger data
|
| 322 |
+
confidences = [
|
| 323 |
+
pf.get("confidence") for pf in per_finger.values()
|
| 324 |
+
if pf.get("status") == "ok" and pf.get("confidence") is not None
|
| 325 |
+
]
|
| 326 |
+
overall_confidence = min(confidences) if confidences else None
|
| 327 |
+
|
| 328 |
+
measurement_id = save_measurement({
|
| 329 |
+
"run_id": run_id,
|
| 330 |
+
"kol_name": kol_name,
|
| 331 |
+
"mode": "multi",
|
| 332 |
+
"ring_model": ring_model,
|
| 333 |
+
"overall_best_size": result.get("overall_best_size"),
|
| 334 |
+
"overall_range_min": result.get("overall_range_min"),
|
| 335 |
+
"overall_range_max": result.get("overall_range_max"),
|
| 336 |
+
"per_finger": per_finger,
|
| 337 |
+
"confidence": overall_confidence,
|
| 338 |
+
"photo_url": photo_public_url,
|
| 339 |
+
"result_url": result_public_url,
|
| 340 |
+
"result_json": result,
|
| 341 |
+
"fail_reason": result.get("fail_reason"),
|
| 342 |
+
})
|
| 343 |
+
|
| 344 |
payload = {
|
| 345 |
"success": result.get("fail_reason") is None,
|
| 346 |
"mode": "multi",
|
|
|
|
| 348 |
"result_image_url": f"/results/{result_png_name}",
|
| 349 |
"input_image_url": input_image_url,
|
| 350 |
"result_json_url": f"/results/{result_json_name}",
|
| 351 |
+
"measurement_id": measurement_id,
|
| 352 |
}
|
| 353 |
|
| 354 |
return jsonify(payload)
|
| 355 |
|
| 356 |
|
| 357 |
+
# ---------------------------------------------------------------------------
|
| 358 |
+
# Admin routes
|
| 359 |
+
# ---------------------------------------------------------------------------
|
| 360 |
+
|
| 361 |
+
@app.route("/admin")
|
| 362 |
+
def admin_page():
|
| 363 |
+
return render_template("admin.html")
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
@app.route("/api/admin/measurements")
|
| 367 |
+
def api_admin_measurements():
|
| 368 |
+
rows = list_measurements(limit=500)
|
| 369 |
+
return jsonify(rows)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
|
| 373 |
+
def api_admin_update_gt(measurement_id: str):
|
| 374 |
+
data = request.get_json(silent=True) or {}
|
| 375 |
+
ok = update_ground_truth(measurement_id, data)
|
| 376 |
+
if ok:
|
| 377 |
+
return jsonify({"success": True})
|
| 378 |
+
return jsonify({"success": False, "error": "Update failed"}), 400
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
@app.route("/api/admin/export-csv")
|
| 382 |
+
def api_admin_export_csv():
|
| 383 |
+
rows = list_measurements(limit=5000)
|
| 384 |
+
output = io.StringIO()
|
| 385 |
+
fieldnames = [
|
| 386 |
+
"kol_name", "created_at", "mode", "ring_model",
|
| 387 |
+
"overall_best_size", "overall_range_min", "overall_range_max",
|
| 388 |
+
"index_size", "index_diameter", "index_confidence",
|
| 389 |
+
"middle_size", "middle_diameter", "middle_confidence",
|
| 390 |
+
"ring_size", "ring_diameter", "ring_confidence",
|
| 391 |
+
"confidence", "fail_reason",
|
| 392 |
+
"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_notes",
|
| 393 |
+
"photo_url", "result_url", "id",
|
| 394 |
+
]
|
| 395 |
+
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
| 396 |
+
writer.writeheader()
|
| 397 |
+
for row in rows:
|
| 398 |
+
flat = {
|
| 399 |
+
"kol_name": row.get("kol_name", ""),
|
| 400 |
+
"created_at": row.get("created_at", ""),
|
| 401 |
+
"mode": row.get("mode", ""),
|
| 402 |
+
"ring_model": row.get("ring_model", ""),
|
| 403 |
+
"overall_best_size": row.get("overall_best_size", ""),
|
| 404 |
+
"overall_range_min": row.get("overall_range_min", ""),
|
| 405 |
+
"overall_range_max": row.get("overall_range_max", ""),
|
| 406 |
+
"confidence": row.get("confidence", ""),
|
| 407 |
+
"fail_reason": row.get("fail_reason", ""),
|
| 408 |
+
"gt_index_size": row.get("gt_index_size", ""),
|
| 409 |
+
"gt_middle_size": row.get("gt_middle_size", ""),
|
| 410 |
+
"gt_ring_size": row.get("gt_ring_size", ""),
|
| 411 |
+
"ring_fit": row.get("ring_fit", ""),
|
| 412 |
+
"gt_notes": row.get("gt_notes", ""),
|
| 413 |
+
"photo_url": row.get("photo_url", ""),
|
| 414 |
+
"result_url": row.get("result_url", ""),
|
| 415 |
+
"id": row.get("id", ""),
|
| 416 |
+
}
|
| 417 |
+
pf = row.get("per_finger") or {}
|
| 418 |
+
for finger in ("index", "middle", "ring"):
|
| 419 |
+
fd = pf.get(finger, {})
|
| 420 |
+
flat[f"{finger}_size"] = fd.get("best_match", "")
|
| 421 |
+
flat[f"{finger}_diameter"] = fd.get("diameter_cm", "")
|
| 422 |
+
flat[f"{finger}_confidence"] = fd.get("confidence", "")
|
| 423 |
+
writer.writerow(flat)
|
| 424 |
+
|
| 425 |
+
return Response(
|
| 426 |
+
output.getvalue(),
|
| 427 |
+
mimetype="text/csv",
|
| 428 |
+
headers={"Content-Disposition": "attachment; filename=kol_measurements.csv"},
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
if __name__ == "__main__":
|
| 433 |
app.run(host="0.0.0.0", port=8000, debug=True)
|
web_demo/static/app.js
CHANGED
|
@@ -100,6 +100,8 @@ const RING_SIZE_TABLES = {
|
|
| 100 |
};
|
| 101 |
const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
|
| 102 |
|
|
|
|
|
|
|
| 103 |
const buildMeasureSettings = () => {
|
| 104 |
const fingerSelect = form.querySelector('select[name="finger_index"]');
|
| 105 |
const aiToggle = document.getElementById("aiExplainToggle");
|
|
@@ -111,6 +113,7 @@ const buildMeasureSettings = () => {
|
|
| 111 |
mode: mode,
|
| 112 |
ring_model: ringModel,
|
| 113 |
ai_explain: aiToggle && aiToggle.checked ? "1" : "0",
|
|
|
|
| 114 |
};
|
| 115 |
};
|
| 116 |
|
|
@@ -312,6 +315,7 @@ form.addEventListener("submit", async (event) => {
|
|
| 312 |
formData.append("mode", settings.mode);
|
| 313 |
formData.append("ring_model", settings.ring_model);
|
| 314 |
formData.append("ai_explain", settings.ai_explain);
|
|
|
|
| 315 |
|
| 316 |
const file = imageInput.files[0];
|
| 317 |
if (file) {
|
|
|
|
| 100 |
};
|
| 101 |
const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
|
| 102 |
|
| 103 |
+
const kolNameInput = document.getElementById("kolNameInput");
|
| 104 |
+
|
| 105 |
const buildMeasureSettings = () => {
|
| 106 |
const fingerSelect = form.querySelector('select[name="finger_index"]');
|
| 107 |
const aiToggle = document.getElementById("aiExplainToggle");
|
|
|
|
| 113 |
mode: mode,
|
| 114 |
ring_model: ringModel,
|
| 115 |
ai_explain: aiToggle && aiToggle.checked ? "1" : "0",
|
| 116 |
+
kol_name: kolNameInput ? kolNameInput.value.trim() : "",
|
| 117 |
};
|
| 118 |
};
|
| 119 |
|
|
|
|
| 315 |
formData.append("mode", settings.mode);
|
| 316 |
formData.append("ring_model", settings.ring_model);
|
| 317 |
formData.append("ai_explain", settings.ai_explain);
|
| 318 |
+
formData.append("kol_name", settings.kol_name);
|
| 319 |
|
| 320 |
const file = imageInput.files[0];
|
| 321 |
if (file) {
|
web_demo/supabase_client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase persistence layer for ring-size-cv web demo.
|
| 2 |
+
|
| 3 |
+
Graceful degradation: if SUPABASE_URL or SUPABASE_SERVICE_KEY env vars
|
| 4 |
+
are missing, all functions return None/empty and the app works without
|
| 5 |
+
persistence.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
_client = None
|
| 18 |
+
_initialized = False
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_client():
|
| 22 |
+
"""Lazy-init Supabase client. Returns None if env vars missing."""
|
| 23 |
+
global _client, _initialized
|
| 24 |
+
if _initialized:
|
| 25 |
+
return _client
|
| 26 |
+
_initialized = True
|
| 27 |
+
|
| 28 |
+
url = os.environ.get("SUPABASE_URL", "").strip()
|
| 29 |
+
key = os.environ.get("SUPABASE_SERVICE_KEY", "").strip()
|
| 30 |
+
if not url or not key:
|
| 31 |
+
logger.warning("SUPABASE_URL or SUPABASE_SERVICE_KEY not set — persistence disabled")
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
from supabase import create_client
|
| 36 |
+
_client = create_client(url, key)
|
| 37 |
+
logger.info("Supabase client initialized (%s)", url)
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.error("Failed to initialize Supabase client: %s", e)
|
| 40 |
+
_client = None
|
| 41 |
+
return _client
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
BUCKET = "ring-measurements"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def upload_file(local_path: str, storage_path: str) -> Optional[str]:
|
| 48 |
+
"""Upload a file to Supabase Storage. Returns public URL or None."""
|
| 49 |
+
client = _get_client()
|
| 50 |
+
if client is None:
|
| 51 |
+
return None
|
| 52 |
+
try:
|
| 53 |
+
with open(local_path, "rb") as f:
|
| 54 |
+
data = f.read()
|
| 55 |
+
# Determine content type
|
| 56 |
+
suffix = Path(local_path).suffix.lower()
|
| 57 |
+
content_type = {
|
| 58 |
+
".jpg": "image/jpeg",
|
| 59 |
+
".jpeg": "image/jpeg",
|
| 60 |
+
".png": "image/png",
|
| 61 |
+
}.get(suffix, "application/octet-stream")
|
| 62 |
+
|
| 63 |
+
client.storage.from_(BUCKET).upload(
|
| 64 |
+
storage_path,
|
| 65 |
+
data,
|
| 66 |
+
file_options={"content-type": content_type},
|
| 67 |
+
)
|
| 68 |
+
public_url = client.storage.from_(BUCKET).get_public_url(storage_path)
|
| 69 |
+
return public_url
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error("Failed to upload %s: %s", storage_path, e)
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def save_measurement(record: Dict[str, Any]) -> Optional[str]:
|
| 76 |
+
"""Insert a measurement record. Returns row UUID or None."""
|
| 77 |
+
client = _get_client()
|
| 78 |
+
if client is None:
|
| 79 |
+
return None
|
| 80 |
+
try:
|
| 81 |
+
resp = client.table("measurements").insert(record).execute()
|
| 82 |
+
if resp.data and len(resp.data) > 0:
|
| 83 |
+
return resp.data[0].get("id")
|
| 84 |
+
return None
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error("Failed to save measurement: %s", e)
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
|
| 91 |
+
"""Fetch measurements for admin page, newest first."""
|
| 92 |
+
client = _get_client()
|
| 93 |
+
if client is None:
|
| 94 |
+
return []
|
| 95 |
+
try:
|
| 96 |
+
resp = (
|
| 97 |
+
client.table("measurements")
|
| 98 |
+
.select("*")
|
| 99 |
+
.order("created_at", desc=True)
|
| 100 |
+
.range(offset, offset + limit - 1)
|
| 101 |
+
.execute()
|
| 102 |
+
)
|
| 103 |
+
return resp.data or []
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error("Failed to list measurements: %s", e)
|
| 106 |
+
return []
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
GT_ALLOWED_FIELDS = {"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_notes"}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def update_ground_truth(measurement_id: str, updates: Dict[str, Any]) -> bool:
|
| 113 |
+
"""Update only ground-truth columns for a measurement."""
|
| 114 |
+
client = _get_client()
|
| 115 |
+
if client is None:
|
| 116 |
+
return False
|
| 117 |
+
# Whitelist filter
|
| 118 |
+
safe = {k: v for k, v in updates.items() if k in GT_ALLOWED_FIELDS}
|
| 119 |
+
if not safe:
|
| 120 |
+
return False
|
| 121 |
+
try:
|
| 122 |
+
client.table("measurements").update(safe).eq("id", measurement_id).execute()
|
| 123 |
+
return True
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error("Failed to update GT for %s: %s", measurement_id, e)
|
| 126 |
+
return False
|
web_demo/templates/admin.html
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Ring Sizer — Admin</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg-1: #f5f1e7;
|
| 10 |
+
--ink: #2b1f1f;
|
| 11 |
+
--ink-soft: #4b3d3d;
|
| 12 |
+
--accent: #bf3a2b;
|
| 13 |
+
--sand: #f9f4ec;
|
| 14 |
+
--shadow: rgba(34, 26, 26, 0.12);
|
| 15 |
+
--border: rgba(45, 33, 33, 0.18);
|
| 16 |
+
}
|
| 17 |
+
* { box-sizing: border-box; }
|
| 18 |
+
body {
|
| 19 |
+
margin: 0; padding: 24px;
|
| 20 |
+
color: var(--ink);
|
| 21 |
+
background: var(--bg-1);
|
| 22 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 23 |
+
font-size: 14px;
|
| 24 |
+
}
|
| 25 |
+
h1 { font-size: 1.5rem; margin: 0 0 8px; }
|
| 26 |
+
.toolbar {
|
| 27 |
+
display: flex; gap: 12px; align-items: center;
|
| 28 |
+
margin-bottom: 16px; flex-wrap: wrap;
|
| 29 |
+
}
|
| 30 |
+
.toolbar a, .toolbar button {
|
| 31 |
+
padding: 6px 14px; border-radius: 6px; text-decoration: none;
|
| 32 |
+
font-size: 13px; cursor: pointer; border: 1px solid var(--border);
|
| 33 |
+
background: #fff; color: var(--ink);
|
| 34 |
+
}
|
| 35 |
+
.toolbar a:hover, .toolbar button:hover { background: var(--sand); }
|
| 36 |
+
.toolbar .count { color: var(--ink-soft); font-size: 13px; }
|
| 37 |
+
table {
|
| 38 |
+
width: 100%; border-collapse: collapse;
|
| 39 |
+
background: #fff; border-radius: 8px;
|
| 40 |
+
box-shadow: 0 1px 4px var(--shadow);
|
| 41 |
+
overflow: hidden;
|
| 42 |
+
}
|
| 43 |
+
th, td {
|
| 44 |
+
padding: 8px 10px; text-align: left;
|
| 45 |
+
border-bottom: 1px solid var(--border);
|
| 46 |
+
white-space: nowrap; font-size: 13px;
|
| 47 |
+
}
|
| 48 |
+
th { background: var(--sand); font-weight: 600; position: sticky; top: 0; z-index: 1; }
|
| 49 |
+
tr:hover td { background: #faf7f2; }
|
| 50 |
+
.thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; cursor: pointer; }
|
| 51 |
+
.fail { color: var(--accent); font-weight: 500; }
|
| 52 |
+
.gt-input {
|
| 53 |
+
width: 48px; padding: 2px 4px; border: 1px solid var(--border);
|
| 54 |
+
border-radius: 4px; font-size: 12px; text-align: center;
|
| 55 |
+
}
|
| 56 |
+
.gt-select {
|
| 57 |
+
padding: 2px 4px; border: 1px solid var(--border);
|
| 58 |
+
border-radius: 4px; font-size: 12px;
|
| 59 |
+
}
|
| 60 |
+
.gt-notes-input {
|
| 61 |
+
width: 100px; padding: 2px 4px; border: 1px solid var(--border);
|
| 62 |
+
border-radius: 4px; font-size: 12px;
|
| 63 |
+
}
|
| 64 |
+
.save-btn {
|
| 65 |
+
padding: 2px 8px; font-size: 11px; cursor: pointer;
|
| 66 |
+
border: 1px solid var(--accent); border-radius: 4px;
|
| 67 |
+
background: #fff; color: var(--accent);
|
| 68 |
+
}
|
| 69 |
+
.save-btn:hover { background: var(--accent); color: #fff; }
|
| 70 |
+
.save-btn.saved { border-color: #2a7; color: #2a7; }
|
| 71 |
+
.finger-cell { font-size: 12px; }
|
| 72 |
+
.finger-cell .size { font-weight: 600; }
|
| 73 |
+
.finger-cell .detail { color: var(--ink-soft); }
|
| 74 |
+
.empty { text-align: center; padding: 48px; color: var(--ink-soft); }
|
| 75 |
+
.scroll-wrap { overflow-x: auto; }
|
| 76 |
+
</style>
|
| 77 |
+
</head>
|
| 78 |
+
<body>
|
| 79 |
+
<h1>KOL Measurement Records</h1>
|
| 80 |
+
<div class="toolbar">
|
| 81 |
+
<a href="/">Back to Demo</a>
|
| 82 |
+
<a href="/api/admin/export-csv" download>Export CSV</a>
|
| 83 |
+
<button id="refreshBtn">Refresh</button>
|
| 84 |
+
<span class="count" id="countLabel">Loading...</span>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div class="scroll-wrap">
|
| 88 |
+
<table>
|
| 89 |
+
<thead>
|
| 90 |
+
<tr>
|
| 91 |
+
<th>KOL</th>
|
| 92 |
+
<th>Date</th>
|
| 93 |
+
<th>Photo</th>
|
| 94 |
+
<th>Mode</th>
|
| 95 |
+
<th>Size</th>
|
| 96 |
+
<th>Range</th>
|
| 97 |
+
<th>Index</th>
|
| 98 |
+
<th>Middle</th>
|
| 99 |
+
<th>Ring</th>
|
| 100 |
+
<th>Conf</th>
|
| 101 |
+
<th>Fail</th>
|
| 102 |
+
<th>GT Idx</th>
|
| 103 |
+
<th>GT Mid</th>
|
| 104 |
+
<th>GT Ring</th>
|
| 105 |
+
<th>Ring Fit</th>
|
| 106 |
+
<th>GT Notes</th>
|
| 107 |
+
<th></th>
|
| 108 |
+
</tr>
|
| 109 |
+
</thead>
|
| 110 |
+
<tbody id="tableBody">
|
| 111 |
+
<tr><td colspan="17" class="empty">Loading...</td></tr>
|
| 112 |
+
</tbody>
|
| 113 |
+
</table>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<script>
|
| 117 |
+
const tbody = document.getElementById("tableBody");
|
| 118 |
+
const countLabel = document.getElementById("countLabel");
|
| 119 |
+
|
| 120 |
+
const fmtDate = (iso) => {
|
| 121 |
+
if (!iso) return "";
|
| 122 |
+
const d = new Date(iso);
|
| 123 |
+
return d.toLocaleDateString("en-CA") + " " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
const fmtFinger = (pf, name) => {
|
| 127 |
+
if (!pf || !pf[name]) return '<span class="detail">-</span>';
|
| 128 |
+
const f = pf[name];
|
| 129 |
+
if (f.status !== "ok") return `<span class="fail">${f.fail_reason || "fail"}</span>`;
|
| 130 |
+
const size = f.best_match != null ? f.best_match : "-";
|
| 131 |
+
const diam = f.diameter_cm != null ? (f.diameter_cm * 10).toFixed(1) + "mm" : "";
|
| 132 |
+
const conf = f.confidence != null ? (f.confidence * 100).toFixed(0) + "%" : "";
|
| 133 |
+
return `<span class="size">${size}</span> <span class="detail">${diam} ${conf}</span>`;
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const loadData = async () => {
|
| 137 |
+
try {
|
| 138 |
+
const resp = await fetch("/api/admin/measurements");
|
| 139 |
+
const rows = await resp.json();
|
| 140 |
+
countLabel.textContent = `${rows.length} records`;
|
| 141 |
+
if (rows.length === 0) {
|
| 142 |
+
tbody.innerHTML = '<tr><td colspan="17" class="empty">No measurements yet</td></tr>';
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
tbody.innerHTML = rows.map((r) => {
|
| 146 |
+
const pf = r.per_finger || {};
|
| 147 |
+
const photoThumb = r.photo_url
|
| 148 |
+
? `<img class="thumb" src="${r.photo_url}" onclick="window.open('${r.photo_url}')" />`
|
| 149 |
+
: "-";
|
| 150 |
+
return `<tr data-id="${r.id}">
|
| 151 |
+
<td><strong>${r.kol_name || "-"}</strong></td>
|
| 152 |
+
<td>${fmtDate(r.created_at)}</td>
|
| 153 |
+
<td>${photoThumb}</td>
|
| 154 |
+
<td>${r.mode || "-"}</td>
|
| 155 |
+
<td><strong>${r.overall_best_size != null ? r.overall_best_size : "-"}</strong></td>
|
| 156 |
+
<td>${r.overall_range_min != null ? r.overall_range_min + "–" + r.overall_range_max : "-"}</td>
|
| 157 |
+
<td class="finger-cell">${fmtFinger(pf, "index")}</td>
|
| 158 |
+
<td class="finger-cell">${fmtFinger(pf, "middle")}</td>
|
| 159 |
+
<td class="finger-cell">${fmtFinger(pf, "ring")}</td>
|
| 160 |
+
<td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
|
| 161 |
+
<td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
|
| 162 |
+
<td><input class="gt-input" data-field="gt_index_size" type="number" min="4" max="15" value="${r.gt_index_size ?? ""}" /></td>
|
| 163 |
+
<td><input class="gt-input" data-field="gt_middle_size" type="number" min="4" max="15" value="${r.gt_middle_size ?? ""}" /></td>
|
| 164 |
+
<td><input class="gt-input" data-field="gt_ring_size" type="number" min="4" max="15" value="${r.gt_ring_size ?? ""}" /></td>
|
| 165 |
+
<td><select class="gt-select" data-field="ring_fit">
|
| 166 |
+
<option value="">-</option>
|
| 167 |
+
<option value="fits_well" ${r.ring_fit === "fits_well" ? "selected" : ""}>Fits well</option>
|
| 168 |
+
<option value="too_tight" ${r.ring_fit === "too_tight" ? "selected" : ""}>Too tight</option>
|
| 169 |
+
<option value="too_loose" ${r.ring_fit === "too_loose" ? "selected" : ""}>Too loose</option>
|
| 170 |
+
</select></td>
|
| 171 |
+
<td><input class="gt-notes-input" data-field="gt_notes" type="text" value="${r.gt_notes || ""}" placeholder="notes" /></td>
|
| 172 |
+
<td><button class="save-btn" onclick="saveGT(this)">Save</button></td>
|
| 173 |
+
</tr>`;
|
| 174 |
+
}).join("");
|
| 175 |
+
} catch (e) {
|
| 176 |
+
tbody.innerHTML = `<tr><td colspan="17" class="empty">Error loading data: ${e.message}</td></tr>`;
|
| 177 |
+
}
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
window.saveGT = async (btn) => {
|
| 181 |
+
const tr = btn.closest("tr");
|
| 182 |
+
const id = tr.dataset.id;
|
| 183 |
+
const data = {};
|
| 184 |
+
tr.querySelectorAll("[data-field]").forEach((el) => {
|
| 185 |
+
const field = el.dataset.field;
|
| 186 |
+
if (el.type === "number") {
|
| 187 |
+
data[field] = el.value ? parseInt(el.value, 10) : null;
|
| 188 |
+
} else {
|
| 189 |
+
data[field] = el.value || null;
|
| 190 |
+
}
|
| 191 |
+
});
|
| 192 |
+
try {
|
| 193 |
+
const resp = await fetch(`/api/admin/measurements/${id}/ground-truth`, {
|
| 194 |
+
method: "POST",
|
| 195 |
+
headers: { "Content-Type": "application/json" },
|
| 196 |
+
body: JSON.stringify(data),
|
| 197 |
+
});
|
| 198 |
+
const result = await resp.json();
|
| 199 |
+
if (result.success) {
|
| 200 |
+
btn.textContent = "Saved";
|
| 201 |
+
btn.classList.add("saved");
|
| 202 |
+
setTimeout(() => { btn.textContent = "Save"; btn.classList.remove("saved"); }, 2000);
|
| 203 |
+
} else {
|
| 204 |
+
alert("Save failed: " + (result.error || "unknown"));
|
| 205 |
+
}
|
| 206 |
+
} catch (e) {
|
| 207 |
+
alert("Network error: " + e.message);
|
| 208 |
+
}
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
document.getElementById("refreshBtn").addEventListener("click", loadData);
|
| 212 |
+
loadData();
|
| 213 |
+
</script>
|
| 214 |
+
</body>
|
| 215 |
+
</html>
|
web_demo/templates/index.html
CHANGED
|
@@ -27,6 +27,10 @@
|
|
| 27 |
</label>
|
| 28 |
|
| 29 |
<div class="controls">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
<label>
|
| 31 |
<span>Mode</span>
|
| 32 |
<select name="mode" id="modeSelect">
|
|
|
|
| 27 |
</label>
|
| 28 |
|
| 29 |
<div class="controls">
|
| 30 |
+
<label>
|
| 31 |
+
<span>Your Name / ID</span>
|
| 32 |
+
<input type="text" name="kol_name" id="kolNameInput" placeholder="e.g. KOL-001 or your name" />
|
| 33 |
+
</label>
|
| 34 |
<label>
|
| 35 |
<span>Mode</span>
|
| 36 |
<select name="mode" id="modeSelect">
|