Download app.py from benresnic/Models: direct link, hf CLI and curl.
- Browser
- Download file 62.8 kB
-
https://huggingface.co/spaces/benresnic/Models/resolve/main/app.py
- Command line
-
hf download hf://spaces/benresnic/Models/app.py
-
curl -L -o app.py https://huggingface.co/spaces/benresnic/Models/resolve/main/app.py
62.8 kB
| # app.py (Shiny for Python UI + Starlette JSON API, published state persisted to HF Dataset Hub) | |
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import json | |
| import os | |
| import hmac | |
| import io | |
| from pathlib import Path | |
| from uuid import uuid4 | |
| from urllib.request import Request as URLRequest, urlopen | |
| from urllib.error import HTTPError, URLError | |
| from shiny import App, ui | |
| from starlette.applications import Starlette | |
| from starlette.requests import Request | |
| from starlette.responses import JSONResponse, Response | |
| from starlette.routing import Mount, Route | |
| import smtplib | |
| from email.message import EmailMessage | |
| # ----------------------------------------------------------------------------- | |
| # HF Hub persistence (FREE): save/load cards_state.json from a dataset repo | |
| # ----------------------------------------------------------------------------- | |
| # Required: | |
| # - Space secret HF_TOKEN = write token | |
| # Optional: | |
| # - Space secret HF_STATE_REPO = "username/models_state" | |
| # If omitted, we infer username from SPACE_ID and use "<user>/models_state" | |
| # ----------------------------------------------------------------------------- | |
| HF_TOKEN = (os.environ.get("HF_TOKEN") or "").strip() | |
| HF_STATE_FILE = (os.environ.get("HF_STATE_FILE") or "cards_state.json").strip() | |
| HF_STATE_REPO_TYPE = "dataset" | |
| SECTIONS = ("Models", "Methods", "Miscellaneous") | |
| def _infer_owner_from_space() -> str: | |
| sid = os.environ.get("SPACE_ID") or os.environ.get("HF_SPACE_ID") or "" | |
| if isinstance(sid, str) and "/" in sid: | |
| return sid.split("/", 1)[0].strip() | |
| return "" | |
| def _normalize_section(x) -> str: | |
| s = str(x or "").strip().lower() | |
| mapping = { | |
| "models": "Models", | |
| "model": "Models", | |
| "methods": "Methods", | |
| "method": "Methods", | |
| "miscellaneous": "Miscellaneous", | |
| "misc": "Miscellaneous", | |
| } | |
| return mapping.get(s, "Models") | |
| def _notification_type(section: str) -> str: | |
| s = _normalize_section(section) | |
| return { | |
| "Models": "Model", | |
| "Methods": "Method", | |
| "Miscellaneous": "Miscellaneous", | |
| }.get(s, "Item") | |
| _owner = _infer_owner_from_space() | |
| HF_STATE_REPO = (os.environ.get("HF_STATE_REPO") or (f"{_owner}/models_state" if _owner else "")).strip() | |
| HUB_ENABLED = bool(HF_TOKEN and HF_STATE_REPO) | |
| try: | |
| from huggingface_hub import hf_hub_download, HfApi, CommitOperationAdd | |
| except Exception: | |
| HUB_ENABLED = False | |
| # ----------------------------------------------------------------------------- | |
| # Shared persistence for PUBLISHED cards (local cache file + Hub sync) | |
| # ----------------------------------------------------------------------------- | |
| def _pick_state_dir() -> Path: | |
| p = Path("/tmp") | |
| p.mkdir(parents=True, exist_ok=True) | |
| return p | |
| STATE_DIR = _pick_state_dir() | |
| STATE_PATH = STATE_DIR / "cards_state.json" | |
| STATE_LOCK = asyncio.Lock() | |
| SERVER_PASSWORD = (os.environ.get("password") or "").strip() | |
| SMTP_HOST = os.environ.get("SMTP_HOST", "").strip() | |
| SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) | |
| SMTP_USER = os.environ.get("SMTP_USER", "").strip() | |
| SMTP_PASS = os.environ.get("SMTP_PASS", "").strip() | |
| SMTP_FROM = os.environ.get("SMTP_FROM", SMTP_USER).strip() | |
| SMTP_TO = [x.strip() for x in os.environ.get("SMTP_TO", "").split(",") if x.strip()] | |
| EMAIL_ENABLED = bool(SMTP_HOST and SMTP_USER and SMTP_PASS and SMTP_TO) | |
| # ----------------------------------------------------------------------------- | |
| # ntfy notifications | |
| # ----------------------------------------------------------------------------- | |
| # Optional secrets: | |
| # - NTFY_URL (default: https://ntfy.sh) | |
| # - NTFY_TOPIC (default: Models) -> publishes to https://ntfy.sh/Models | |
| # - NTFY_TOKEN (Bearer auth, optional) | |
| # - NTFY_USER / NTFY_PASS (Basic auth, optional) | |
| # ----------------------------------------------------------------------------- | |
| NTFY_URL = (os.environ.get("NTFY_URL") or "https://ntfy.sh").strip().rstrip("/") | |
| NTFY_TOPIC = (os.environ.get("NTFY_TOPIC") or "Models").strip().strip("/") | |
| NTFY_TOKEN = (os.environ.get("NTFY_TOKEN") or "").strip() | |
| NTFY_USER = (os.environ.get("NTFY_USER") or "").strip() | |
| NTFY_PASS = (os.environ.get("NTFY_PASS") or "").strip() | |
| NTFY_ENABLED = bool(NTFY_URL and NTFY_TOPIC) | |
| def _send_email(subject: str, body: str) -> None: | |
| if not EMAIL_ENABLED: | |
| return | |
| msg = EmailMessage() | |
| msg["Subject"] = subject | |
| msg["From"] = SMTP_FROM | |
| msg["To"] = ", ".join(SMTP_TO) | |
| msg.set_content(body) | |
| with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s: | |
| s.starttls() | |
| s.login(SMTP_USER, SMTP_PASS) | |
| s.send_message(msg) | |
| def _send_ntfy(title: str, body: str, *, tags: list[str] | None = None, priority: int | str | None = None) -> None: | |
| if not NTFY_ENABLED: | |
| return | |
| try: | |
| topic = NTFY_TOPIC.lstrip("/") | |
| url = f"{NTFY_URL}/{topic}" | |
| headers = { | |
| "Content-Type": "text/plain; charset=utf-8", | |
| "Title": title, | |
| } | |
| clean_tags = [] | |
| for t in (tags or []): | |
| s = str(t or "").strip().replace(",", " ") | |
| if s: | |
| clean_tags.append(s) | |
| if clean_tags: | |
| headers["Tags"] = ",".join(clean_tags) | |
| if priority is not None: | |
| headers["Priority"] = str(priority) | |
| if NTFY_TOKEN: | |
| headers["Authorization"] = f"Bearer {NTFY_TOKEN}" | |
| elif NTFY_USER and NTFY_PASS: | |
| raw = f"{NTFY_USER}:{NTFY_PASS}".encode("utf-8") | |
| headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii") | |
| req = URLRequest( | |
| url, | |
| data=body.encode("utf-8"), | |
| headers=headers, | |
| method="POST", | |
| ) | |
| with urlopen(req, timeout=20) as resp: | |
| resp.read() | |
| except (HTTPError, URLError, OSError): | |
| pass | |
| except Exception: | |
| pass | |
| def _hub_pull_into_local() -> None: | |
| """If local state missing, download cards_state.json from dataset repo.""" | |
| if not HUB_ENABLED: | |
| return | |
| if STATE_PATH.exists(): | |
| return | |
| try: | |
| downloaded = hf_hub_download( | |
| repo_id=HF_STATE_REPO, | |
| repo_type=HF_STATE_REPO_TYPE, | |
| filename=HF_STATE_FILE, | |
| token=HF_TOKEN, | |
| ) | |
| STATE_PATH.write_text(Path(downloaded).read_text(encoding="utf-8"), encoding="utf-8") | |
| except Exception: | |
| pass | |
| def _hub_push_state(state: dict) -> None: | |
| """Upload current state JSON to dataset repo.""" | |
| if not HUB_ENABLED: | |
| return | |
| try: | |
| api = HfApi(token=HF_TOKEN) | |
| content = json.dumps(state, indent=2, ensure_ascii=False).encode("utf-8") | |
| op = CommitOperationAdd( | |
| path_in_repo=HF_STATE_FILE, | |
| path_or_fileobj=io.BytesIO(content), | |
| ) | |
| api.create_commit( | |
| repo_id=HF_STATE_REPO, | |
| repo_type=HF_STATE_REPO_TYPE, | |
| operations=[op], | |
| commit_message="Update cards_state.json", | |
| ) | |
| except Exception: | |
| pass | |
| def _normalize_tags(x) -> list[str]: | |
| if x is None: | |
| return [] | |
| if isinstance(x, str): | |
| x = [t.strip() for t in x.split(",")] | |
| if not isinstance(x, list): | |
| return [] | |
| out: list[str] = [] | |
| for t in x: | |
| if t is None: | |
| continue | |
| s = str(t).strip() | |
| if s and s not in out: | |
| out.append(s) | |
| return out | |
| def _new_published_card(payload: dict, *, card_id: str | None = None) -> dict: | |
| return { | |
| "id": card_id or str(uuid4())[:12], | |
| "section": _normalize_section(payload.get("section")), | |
| "model": str(payload.get("model") or "").strip(), | |
| "tags": _normalize_tags(payload.get("tags")), | |
| "how_it_works": str(payload.get("how_it_works") or "").strip(), | |
| "use_cases": str(payload.get("use_cases") or "").strip(), | |
| "link": str(payload.get("link") or "").strip(), | |
| "author": str(payload.get("author") or "").strip(), | |
| } | |
| def _normalize_state(obj: dict) -> dict: | |
| if not isinstance(obj, dict): | |
| obj = {} | |
| version = obj.get("version", 0) | |
| if not isinstance(version, int): | |
| version = 0 | |
| cards = obj.get("cards", []) | |
| if not isinstance(cards, list): | |
| cards = [] | |
| normalized = [] | |
| for c in cards: | |
| if not isinstance(c, dict): | |
| continue | |
| normalized.append( | |
| { | |
| "id": str(c.get("id") or str(uuid4())[:12]), | |
| "section": _normalize_section(c.get("section") or c.get("tab") or c.get("category")), | |
| "model": str(c.get("model") or c.get("title") or ""), | |
| "tags": _normalize_tags(c.get("tags") or c.get("categories") or []), | |
| "how_it_works": str(c.get("how_it_works") or c.get("how") or ""), | |
| "use_cases": str(c.get("use_cases") or c.get("use") or ""), | |
| "link": str(c.get("link") or c.get("resource_url") or ""), | |
| "author": str(c.get("author") or c.get("by") or ""), | |
| } | |
| ) | |
| return {"version": version, "cards": normalized} | |
| def _read_state_sync() -> dict: | |
| _hub_pull_into_local() | |
| if not STATE_PATH.exists(): | |
| return {"version": 0, "cards": []} | |
| try: | |
| return _normalize_state(json.loads(STATE_PATH.read_text(encoding="utf-8"))) | |
| except Exception: | |
| return {"version": 0, "cards": []} | |
| def _write_state_sync(state: dict) -> None: | |
| tmp = STATE_PATH.with_suffix(".tmp") | |
| tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") | |
| tmp.replace(STATE_PATH) | |
| _hub_push_state(state) | |
| async def get_state() -> dict: | |
| async with STATE_LOCK: | |
| return _read_state_sync() | |
| async def set_state(state: dict) -> None: | |
| async with STATE_LOCK: | |
| _write_state_sync(state) | |
| def _json(data: dict, status_code: int = 200) -> JSONResponse: | |
| return JSONResponse(data, status_code=status_code, headers={"Cache-Control": "no-store"}) | |
| def _check_password(request: Request, payload: dict | None = None) -> bool: | |
| if not SERVER_PASSWORD: | |
| return False | |
| provided = request.headers.get("X-Password") | |
| if not provided and isinstance(payload, dict): | |
| provided = payload.get("password") | |
| if not isinstance(provided, str): | |
| return False | |
| return hmac.compare_digest(provided.strip(), SERVER_PASSWORD) | |
| # ----------------------------------------------------------------------------- | |
| # API: published cards only | |
| # ----------------------------------------------------------------------------- | |
| async def api_cards(request: Request) -> Response: | |
| if request.method == "GET": | |
| return _json(await get_state()) | |
| if request.method == "POST": | |
| try: | |
| payload = await request.json() | |
| except Exception: | |
| payload = {} | |
| if not isinstance(payload, dict) or not _check_password(request, payload): | |
| msg = ( | |
| "Unauthorized (check Space secret 'password')" | |
| if SERVER_PASSWORD | |
| else "Password not configured" | |
| ) | |
| return _json({"error": msg}, status_code=401) | |
| card = _new_published_card(payload) | |
| if not card["model"] or not card["how_it_works"] or not card["use_cases"]: | |
| return _json( | |
| {"error": "Model, How it Works, and Use Cases are required"}, | |
| status_code=400, | |
| ) | |
| state = await get_state() | |
| state["cards"].append(card) | |
| state["version"] += 1 | |
| await set_state(state) | |
| if EMAIL_ENABLED: | |
| subject = f"New {card.get('section', 'Models')} card posted: {card.get('model', '')}" | |
| body = ( | |
| f"Section: {card.get('section','')}\n" | |
| f"Model: {card.get('model','')}\n" | |
| f"Tags: {', '.join(card.get('tags', []) or [])}\n" | |
| f"By: {card.get('author','')}\n" | |
| f"Link: {card.get('link','')}\n" | |
| ) | |
| asyncio.create_task(asyncio.to_thread(_send_email, subject, body)) | |
| if NTFY_ENABLED: | |
| item_type = _notification_type(card.get("section", "Models")) | |
| maker = card.get("author", "").strip() or "Unknown" | |
| name = card.get("model", "").strip() or "Untitled" | |
| title = f"New {item_type} Added" | |
| body = f"{name} by {maker}" | |
| asyncio.create_task( | |
| asyncio.to_thread( | |
| _send_ntfy, | |
| title, | |
| body, | |
| tags=["tada"], | |
| priority=3, | |
| ) | |
| ) | |
| return _json(state) | |
| return _json({"error": "Method not allowed"}, status_code=405) | |
| async def api_card_id(request: Request) -> Response: | |
| card_id = request.path_params.get("card_id", "") | |
| state = await get_state() | |
| cards = state.get("cards", []) | |
| idx = next((i for i, c in enumerate(cards) if c.get("id") == card_id), None) | |
| if idx is None: | |
| return _json({"error": "Card not found"}, status_code=404) | |
| if request.method == "PUT": | |
| try: | |
| payload = await request.json() | |
| except Exception: | |
| payload = {} | |
| if not isinstance(payload, dict) or not _check_password(request, payload): | |
| msg = "Unauthorized (check Space secret 'password')" if SERVER_PASSWORD else "Password not configured" | |
| return _json({"error": msg}, status_code=401) | |
| updated = _new_published_card(payload, card_id=card_id) | |
| if not updated["model"] or not updated["how_it_works"] or not updated["use_cases"]: | |
| return _json({"error": "Model, How it Works, and Use Cases are required"}, status_code=400) | |
| cards[idx] = updated | |
| state["version"] += 1 | |
| await set_state(state) | |
| return _json(state) | |
| if request.method == "DELETE": | |
| if not _check_password(request): | |
| msg = "Unauthorized (check Space secret 'password')" if SERVER_PASSWORD else "Password not configured" | |
| return _json({"error": msg}, status_code=401) | |
| cards.pop(idx) | |
| state["version"] += 1 | |
| await set_state(state) | |
| return _json(state) | |
| return _json({"error": "Method not allowed"}, status_code=405) | |
| # ----------------------------------------------------------------------------- | |
| # UI | |
| # ----------------------------------------------------------------------------- | |
| app_ui = ui.page_fluid( | |
| ui.tags.style( | |
| r""" | |
| :root{ | |
| --navy:#062a4d; | |
| --navy2:#041f3a; | |
| --cardBorder:#d8e1ee; | |
| --cardBg:#ffffff; | |
| --muted:#6b778c; | |
| --headerBg:#f3f6fb; | |
| --good:#167d3a; | |
| } | |
| body { background: #f3f6fb; } | |
| input:not([type="checkbox"]):not([type="radio"]), | |
| select { | |
| width: 100%; | |
| box-sizing: border-box; | |
| border: 1px solid #d8e1ee; | |
| border-radius: 12px; | |
| padding: 10px 12px; | |
| font-size: 14px; | |
| outline: none; | |
| background: white; | |
| } | |
| input[readonly]:not([type="checkbox"]):not([type="radio"]) { | |
| background:#f8fafc; | |
| } | |
| select[disabled]{ | |
| background:#f8fafc; | |
| } | |
| textarea{ | |
| width:100%; | |
| box-sizing:border-box; | |
| border: 1px solid #d8e1ee; | |
| border-radius: 12px; | |
| padding: 10px 12px; | |
| font-size: 14px; | |
| outline: none; | |
| background: white; | |
| resize: none; | |
| overflow: auto; | |
| line-height: 1.25; | |
| } | |
| textarea[readonly]{ background:#f8fafc; } | |
| label{ | |
| display:block; | |
| font-size: 12px; | |
| color: var(--muted); | |
| margin: 0 0 5px; | |
| font-weight: 750; | |
| letter-spacing: .2px; | |
| } | |
| .topbar{ | |
| background: linear-gradient(0deg, var(--navy2), var(--navy)); | |
| padding: 12px 14px; | |
| border-radius: 0 0 14px 14px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,.12); | |
| display:flex; | |
| justify-content:space-between; | |
| align-items:center; | |
| gap: 12px; | |
| margin-bottom: 8px; | |
| } | |
| .top-left{ | |
| display:flex; | |
| align-items:center; | |
| gap: 10px; | |
| min-width: 0; | |
| flex: 1 1 auto; | |
| } | |
| .chips-row{ | |
| display:flex; | |
| gap: 8px; | |
| align-items:center; | |
| overflow-x:auto; | |
| padding: 2px 4px; | |
| min-width: 0; | |
| flex: 1 1 auto; | |
| } | |
| .chips-row::-webkit-scrollbar{ display:none; } | |
| .chip{ | |
| border: 1px solid rgba(255,255,255,.25); | |
| background: rgba(255,255,255,.12); | |
| color: #fff; | |
| border-radius: 999px; | |
| padding: 7px 10px; | |
| font-size: 12px; | |
| font-weight: 850; | |
| cursor: pointer; | |
| user-select:none; | |
| white-space: nowrap; | |
| } | |
| .chip.active{ | |
| background: rgba(255,255,255,.95); | |
| color: var(--navy); | |
| border-color: rgba(0,0,0,.08); | |
| } | |
| #clear-filters{ | |
| border-radius: 12px; | |
| padding: 9px 12px; | |
| font-weight: 900; | |
| font-size: 13px; | |
| cursor:pointer; | |
| white-space: nowrap; | |
| background: rgba(255,255,255,.12); | |
| color: #fff; | |
| border: 1px solid rgba(255,255,255,.25); | |
| } | |
| #add-btn{ | |
| background: white; | |
| color: var(--navy); | |
| border: 1px solid rgba(0,0,0,.08); | |
| border-radius: 12px; | |
| padding: 12px 20px; | |
| font-weight: 950; | |
| font-size: 22px; | |
| line-height: 1; | |
| cursor: pointer; | |
| box-shadow: 0 2px 8px rgba(0,0,0,.14); | |
| flex: 0 0 auto; | |
| } | |
| .section-tabs{ | |
| display:flex; | |
| gap: 10px; | |
| padding: 0 16px 10px 16px; | |
| flex-wrap: wrap; | |
| } | |
| .section-tab{ | |
| border: 1px solid #d8e1ee; | |
| background: white; | |
| color: #1f2a44; | |
| border-radius: 999px; | |
| padding: 9px 14px; | |
| font-size: 13px; | |
| font-weight: 900; | |
| cursor:pointer; | |
| } | |
| .section-tab.active{ | |
| background: var(--navy); | |
| color: white; | |
| border-color: var(--navy); | |
| } | |
| .err{ | |
| margin: 0 16px 10px 16px; | |
| padding: 10px 12px; | |
| border-radius: 10px; | |
| background: #fff1f2; | |
| border: 1px solid #fecdd3; | |
| color: #9f1239; | |
| font-size: 13px; | |
| display:none; | |
| } | |
| .cards-grid{ | |
| display:grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 22px; | |
| padding: 10px 16px 22px 16px; | |
| width: 100%; | |
| margin: 0; | |
| align-items:start; | |
| } | |
| @media (max-width: 900px){ | |
| .cards-grid{ grid-template-columns: 1fr; } | |
| } | |
| .card{ | |
| background: var(--cardBg); | |
| border: 1px solid var(--cardBorder); | |
| border-radius: 14px; | |
| box-shadow: 0 1px 0 rgba(0,0,0,.02); | |
| aspect-ratio: 1 / 1; | |
| width: 100%; | |
| overflow: hidden; | |
| display:flex; | |
| flex-direction:column; | |
| min-height: 0; | |
| } | |
| .card-header{ | |
| background: var(--headerBg); | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| gap: 10px; | |
| padding: 10px 10px 8px 10px; | |
| border-bottom: 1px solid #eef2f8; | |
| flex: 0 0 auto; | |
| } | |
| .header-left{ | |
| display:flex; | |
| align-items:center; | |
| gap: 10px; | |
| flex: 1 1 auto; | |
| min-width: 0; | |
| } | |
| .title-input, .title-text{ | |
| flex: 1 1 auto; | |
| min-width: 140px; | |
| border: none; | |
| outline: none; | |
| background: transparent; | |
| padding: 6px 8px; | |
| border-radius: 10px; | |
| font-size: 16px; | |
| font-weight: 650; | |
| color: #1f2a44; | |
| } | |
| .title-input::placeholder{ color: #7b879c; font-weight: 650; } | |
| .card.has-title .title-input{ font-weight: 950; } | |
| .title-input:focus{ | |
| background: rgba(255,255,255,.85); | |
| border: 1px solid #d8e1ee; | |
| } | |
| .title-text{ font-weight: 950; } | |
| .tag-bar{ | |
| display:flex; | |
| gap: 6px; | |
| align-items:center; | |
| flex: 0 0 auto; | |
| max-width: 52%; | |
| overflow:auto; | |
| padding-bottom: 2px; | |
| } | |
| .tag-bar::-webkit-scrollbar{ display:none; } | |
| .tag-chip{ | |
| border: 1px solid #d8e1ee; | |
| background: #fff; | |
| color: #1f2a44; | |
| border-radius: 999px; | |
| padding: 6px 9px; | |
| font-size: 11px; | |
| font-weight: 850; | |
| display:inline-flex; | |
| align-items:center; | |
| gap: 6px; | |
| cursor:pointer; | |
| white-space: nowrap; | |
| } | |
| .tag-chip .rm{ font-weight: 950; opacity: .6; } | |
| .tag-btn{ | |
| border: 1px dashed #cfd9ea; | |
| background: transparent; | |
| border-radius: 999px; | |
| padding: 6px 10px; | |
| font-size: 11px; | |
| font-weight: 900; | |
| cursor:pointer; | |
| white-space:nowrap; | |
| } | |
| .hdr-right{ | |
| display:flex; | |
| align-items:center; | |
| gap: 8px; | |
| flex: 0 0 auto; | |
| } | |
| .section-pill{ | |
| font-size: 11px; | |
| font-weight: 950; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: #edf4ff; | |
| border: 1px solid #cfe0ff; | |
| color: #1f4d8f; | |
| user-select:none; | |
| } | |
| .draft-badge{ | |
| font-size: 11px; | |
| font-weight: 950; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: #fff7ed; | |
| border: 1px solid #fed7aa; | |
| color: #9a3412; | |
| user-select:none; | |
| } | |
| .post-btn, .edit-btn{ | |
| border-radius: 12px; | |
| padding: 8px 12px; | |
| font-weight: 950; | |
| border: 1px solid rgba(0,0,0,.08); | |
| cursor:pointer; | |
| white-space:nowrap; | |
| background: #fff; | |
| color: #1f2a44; | |
| } | |
| .post-btn{ | |
| background: #eaf7ef; | |
| color: var(--good); | |
| } | |
| .post-btn:disabled{ opacity: .55; cursor: not-allowed; } | |
| .del-btn{ | |
| border: none; | |
| background: transparent; | |
| color: #7b879c; | |
| font-weight: 900; | |
| font-size: 20px; | |
| cursor:pointer; | |
| padding: 0 8px; | |
| flex: 0 0 auto; | |
| } | |
| .card-main{ | |
| padding: 12px; | |
| display:flex; | |
| flex-direction:column; | |
| gap: 12px; | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| overflow:hidden; | |
| } | |
| .panel{ | |
| display:flex; | |
| flex-direction:column; | |
| flex: 1 1 0; | |
| min-height: 0; | |
| } | |
| .panel textarea{ | |
| flex: 1 1 0; | |
| min-height: 0; | |
| } | |
| .card-footer{ | |
| padding: 10px 12px 12px 12px; | |
| border-top: 1px solid #eef2f8; | |
| flex: 0 0 auto; | |
| } | |
| .footer-row{ | |
| display:flex; | |
| align-items:flex-end; | |
| gap: 10px; | |
| } | |
| .section-col{ | |
| flex: 0 0 155px; | |
| min-width: 130px; | |
| } | |
| .link-col{ | |
| flex: 1 1 auto; | |
| min-width: 0; | |
| } | |
| .by-col{ | |
| flex: 0 0 auto; | |
| display:flex; | |
| align-items:center; | |
| gap: 8px; | |
| white-space: nowrap; | |
| font-size: 12px; | |
| font-weight: 900; | |
| color:#1f2a44; | |
| } | |
| .by-input{ | |
| width: 160px; | |
| padding: 8px 10px; | |
| border-radius: 10px; | |
| font-size: 12px; | |
| } | |
| .by-display{ | |
| width: 200px; | |
| text-align:right; | |
| font-size: 12px; | |
| font-weight: 900; | |
| color:#1f2a44; | |
| overflow:hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .resource-link{ display:inline-block; margin-top: 8px; font-size: 13px; } | |
| .empty{ | |
| margin: 0 16px; | |
| padding: 18px; | |
| background: white; | |
| border: 1px dashed #cfd9ea; | |
| border-radius: 12px; | |
| color: #41516b; | |
| } | |
| .dlg-backdrop{ | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,.35); | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| z-index: 999999; | |
| padding: 14px; | |
| } | |
| .dlg{ | |
| width: min(560px, calc(100vw - 28px)); | |
| max-height: 80vh; | |
| background: white; | |
| border-radius: 14px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,.25); | |
| display:flex; | |
| flex-direction:column; | |
| overflow:hidden; | |
| } | |
| .dlg .head{ | |
| padding: 12px 14px 10px 14px; | |
| border-bottom: 1px solid #eef2f8; | |
| } | |
| .dlg .head h3{ margin: 0 0 6px 0; font-size: 16px; } | |
| .dlg .muted{ color: var(--muted); font-size: 13px; } | |
| .dlg .body{ | |
| padding: 12px 14px; | |
| overflow:auto; | |
| } | |
| .dlg .foot{ | |
| padding: 10px 14px 12px 14px; | |
| border-top: 1px solid #eef2f8; | |
| display:flex; | |
| gap: 10px; | |
| justify-content:flex-end; | |
| } | |
| .btn{ | |
| border-radius: 12px; | |
| padding: 9px 12px; | |
| font-weight: 900; | |
| cursor:pointer; | |
| border: 1px solid #d8e1ee; | |
| background: #ffffff; | |
| } | |
| .btn-primary{ | |
| background:#eaf7ef; | |
| border-color:#bbf7d0; | |
| color: var(--good); | |
| } | |
| .btn-danger{ | |
| border: 1px solid #fecdd3; | |
| background: #fff1f2; | |
| color: #9f1239; | |
| } | |
| .btn[disabled]{ opacity: .55; cursor: not-allowed; } | |
| .checklist{ | |
| display:flex; | |
| flex-direction:column; | |
| gap: 6px; | |
| } | |
| .checkrow{ | |
| display:flex; | |
| align-items:center; | |
| gap: 10px; | |
| padding: 6px 8px; | |
| border-radius: 10px; | |
| } | |
| .checkrow:hover{ background:#f6f8fe; } | |
| .checkrow input[type="checkbox"]{ width:auto !important; } | |
| .checkrow .lbl{ | |
| font-weight: 850; | |
| font-size: 12px; | |
| color:#1f2a44; | |
| line-height: 1.1; | |
| } | |
| .row{ | |
| display:flex; | |
| gap: 10px; | |
| align-items:center; | |
| margin-top: 10px; | |
| } | |
| @media (max-width: 900px){ | |
| .footer-row{ | |
| flex-wrap: wrap; | |
| align-items:flex-start; | |
| } | |
| .section-col, .link-col, .by-col{ | |
| flex: 1 1 100%; | |
| } | |
| .by-display{ | |
| width: auto; | |
| text-align: left; | |
| } | |
| } | |
| """ | |
| ), | |
| ui.tags.div( | |
| {"class": "topbar"}, | |
| ui.tags.div( | |
| {"class": "top-left"}, | |
| ui.tags.div({"id": "chips-row", "class": "chips-row"}), | |
| ui.tags.button("Clear", {"id": "clear-filters", "type": "button"}), | |
| ), | |
| ui.tags.button("+", {"id": "add-btn", "type": "button"}), | |
| ), | |
| ui.tags.div({"id": "section-tabs", "class": "section-tabs"}), | |
| ui.tags.div({"id": "err", "class": "err"}), | |
| ui.tags.div({"id": "grid", "class": "cards-grid"}), | |
| ui.tags.script( | |
| r""" | |
| (() => { | |
| const DRAFTS_KEY = "model_cards_drafts_v6"; | |
| const SECTION_KEY = "model_cards_section_v1"; | |
| const grid = document.getElementById("grid"); | |
| const addBtn = document.getElementById("add-btn"); | |
| const errBox = document.getElementById("err"); | |
| const chipsRow = document.getElementById("chips-row"); | |
| const clearBtn = document.getElementById("clear-filters"); | |
| const sectionTabs = document.getElementById("section-tabs"); | |
| const SECTIONS = ["Models", "Methods", "Miscellaneous"]; | |
| const COMMON_TAGS = [ | |
| "Regression","Classification","Boosting","Bagging","Supervised","Unsupervised", | |
| "Clustering","Dimensionality Reduction","Linear","Additive","Deep Learning", | |
| "Neural Net","Transformer","Ensemble","Bayesian","Causal","Multilevel", | |
| "NLP","Tree-based","Black Box" | |
| ]; | |
| const state = { version: null, published: [] }; | |
| let drafts = loadDrafts(); | |
| const saveTimers = new Map(); | |
| const filterTags = new Set(); | |
| let currentSection = normalizeSection(localStorage.getItem(SECTION_KEY) || "Models"); | |
| function normalizeSection(s){ | |
| const v = String(s || "").trim().toLowerCase(); | |
| if(v === "models" || v === "model") return "Models"; | |
| if(v === "methods" || v === "method") return "Methods"; | |
| if(v === "miscellaneous" || v === "misc") return "Miscellaneous"; | |
| return "Models"; | |
| } | |
| function saveCurrentSection(){ | |
| localStorage.setItem(SECTION_KEY, currentSection); | |
| } | |
| function sectionOptions(selected){ | |
| const s = normalizeSection(selected); | |
| return SECTIONS.map(x => ( | |
| `<option value="${escapeAttr(x)}" ${x === s ? "selected" : ""}>${escapeHtml(x)}</option>` | |
| )).join(""); | |
| } | |
| function showErr(msg){ | |
| if(!msg){ errBox.style.display="none"; errBox.textContent=""; return; } | |
| errBox.style.display="block"; | |
| errBox.textContent = msg; | |
| } | |
| function escapeHtml(str){ | |
| return String(str ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function escapeAttr(str){ | |
| return String(str ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll('"', """) | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">"); | |
| } | |
| async function api(path, opts={}){ | |
| const res = await fetch(path, { | |
| headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, | |
| cache: "no-store", | |
| ...opts | |
| }); | |
| const data = await res.json().catch(() => ({})); | |
| if(!res.ok){ | |
| throw new Error(data?.error || ("HTTP " + res.status)); | |
| } | |
| return data; | |
| } | |
| function uid(){ | |
| return "draft_" + (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)); | |
| } | |
| function loadDrafts(){ | |
| try{ | |
| const raw = localStorage.getItem(DRAFTS_KEY); | |
| const arr = JSON.parse(raw || "[]"); | |
| if(!Array.isArray(arr)) return []; | |
| return arr.map(d => ({ | |
| id: String(d.id || uid()), | |
| section: normalizeSection(d.section || "Models"), | |
| model: String(d.model || ""), | |
| tags: Array.isArray(d.tags) ? d.tags.map(String) : [], | |
| how_it_works: String(d.how_it_works || ""), | |
| use_cases: String(d.use_cases || ""), | |
| link: String(d.link || ""), | |
| author: String(d.author || ""), | |
| })); | |
| }catch(e){ | |
| return []; | |
| } | |
| } | |
| function saveDrafts(){ | |
| localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts)); | |
| } | |
| function uniq(arr){ | |
| const out = []; | |
| const seen = new Set(); | |
| for(const x of arr){ | |
| const s = String(x || "").trim(); | |
| if(!s) continue; | |
| if(seen.has(s)) continue; | |
| seen.add(s); | |
| out.push(s); | |
| } | |
| return out; | |
| } | |
| function tagUniverse(){ | |
| const fromPublished = []; | |
| const fromDrafts = []; | |
| for(const c of state.published){ | |
| if(normalizeSection(c.section) !== currentSection) continue; | |
| (c.tags || []).forEach(t => fromPublished.push(t)); | |
| } | |
| for(const d of drafts){ | |
| if(normalizeSection(d.section) !== currentSection) continue; | |
| (d.tags || []).forEach(t => fromDrafts.push(t)); | |
| } | |
| return uniq([...COMMON_TAGS, ...fromPublished, ...fromDrafts]).sort((a,b)=>a.localeCompare(b)); | |
| } | |
| function passesFilter(card){ | |
| if(normalizeSection(card.section) !== currentSection) return false; | |
| if(filterTags.size === 0) return true; | |
| const tags = new Set((card.tags || []).map(String)); | |
| for(const f of filterTags){ | |
| if(tags.has(f)) return true; | |
| } | |
| return false; | |
| } | |
| function renderSectionTabs(){ | |
| sectionTabs.innerHTML = SECTIONS.map(s => { | |
| const active = s === currentSection ? "active" : ""; | |
| return `<button class="section-tab ${active}" data-section="${escapeAttr(s)}" type="button">${escapeHtml(s)}</button>`; | |
| }).join(""); | |
| sectionTabs.querySelectorAll(".section-tab").forEach(btn => { | |
| btn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| currentSection = normalizeSection(btn.getAttribute("data-section")); | |
| saveCurrentSection(); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }); | |
| }); | |
| } | |
| function renderFilterChips(){ | |
| const all = tagUniverse(); | |
| chipsRow.innerHTML = all.map(t => { | |
| const active = filterTags.has(t) ? "active" : ""; | |
| return `<span class="chip ${active}" data-tag="${escapeAttr(t)}">${escapeHtml(t)}</span>`; | |
| }).join(""); | |
| chipsRow.querySelectorAll(".chip").forEach(ch => { | |
| ch.addEventListener("click", () => { | |
| const t = ch.getAttribute("data-tag"); | |
| if(filterTags.has(t)) filterTags.delete(t); | |
| else filterTags.add(t); | |
| renderFilterChips(); | |
| render(); | |
| }); | |
| }); | |
| } | |
| clearBtn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| filterTags.clear(); | |
| renderFilterChips(); | |
| render(); | |
| }); | |
| function openDialog({title, subtitle, bodyHtml, primaryText, primaryClass, canSubmit, onSubmit, onOpen}){ | |
| const backdrop = document.createElement("div"); | |
| backdrop.className = "dlg-backdrop"; | |
| backdrop.innerHTML = ` | |
| <div class="dlg" role="dialog" aria-modal="true"> | |
| <div class="head"> | |
| <h3>${escapeHtml(title)}</h3> | |
| <div class="muted">${escapeHtml(subtitle || "")}</div> | |
| </div> | |
| <div class="body">${bodyHtml}</div> | |
| <div class="foot"> | |
| <button class="btn" data-act="cancel" type="button">Cancel</button> | |
| <button class="btn ${primaryClass || ""}" data-act="primary" type="button" disabled>${escapeHtml(primaryText || "Confirm")}</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(backdrop); | |
| const btnCancel = backdrop.querySelector('[data-act="cancel"]'); | |
| const btnPrimary = backdrop.querySelector('[data-act="primary"]'); | |
| function close(){ backdrop.remove(); } | |
| function refresh(){ | |
| try{ | |
| btnPrimary.disabled = !(canSubmit ? canSubmit(backdrop) : true); | |
| }catch{ | |
| btnPrimary.disabled = true; | |
| } | |
| } | |
| async function submit(){ | |
| if(btnPrimary.disabled) return; | |
| try{ | |
| await onSubmit(backdrop); | |
| }catch(e){ | |
| showErr(String(e.message || e)); | |
| return; | |
| } | |
| close(); | |
| } | |
| btnCancel.addEventListener("click", (e) => { e.preventDefault(); close(); }); | |
| backdrop.addEventListener("click", (e) => { if(e.target === backdrop) close(); }); | |
| btnPrimary.addEventListener("click", (e) => { e.preventDefault(); submit(); }); | |
| backdrop.addEventListener("keydown", (e) => { | |
| if(e.key === "Enter"){ | |
| const active = document.activeElement; | |
| if(active && active.tagName === "TEXTAREA") return; | |
| if(!btnPrimary.disabled){ | |
| e.preventDefault(); | |
| submit(); | |
| } | |
| } | |
| }); | |
| refresh(); | |
| if(onOpen) onOpen({backdrop, refresh, close}); | |
| return { close, refresh }; | |
| } | |
| function openTagsDialog({selected, onApply}){ | |
| const all = tagUniverse(); | |
| const selectedSet = new Set(selected); | |
| const listHtml = all.map(t => { | |
| const checked = selectedSet.has(t) ? "checked" : ""; | |
| return ` | |
| <div class="checkrow"> | |
| <input type="checkbox" class="tg" data-tag="${escapeAttr(t)}" ${checked}/> | |
| <div class="lbl">${escapeHtml(t)}</div> | |
| </div> | |
| `; | |
| }).join(""); | |
| openDialog({ | |
| title: "Tags", | |
| subtitle: "Check all that apply.", | |
| bodyHtml: ` | |
| <div class="checklist">${listHtml}</div> | |
| <div class="row"> | |
| <input id="custom" type="text" placeholder="Add custom tag…" /> | |
| <button class="btn" id="addCustom" type="button">Add</button> | |
| </div> | |
| `, | |
| primaryText: "Apply", | |
| primaryClass: "btn-primary", | |
| canSubmit: () => true, | |
| onSubmit: async (backdrop) => { | |
| const checked = []; | |
| backdrop.querySelectorAll(".tg").forEach(cb => { | |
| if(cb.checked) checked.push(cb.getAttribute("data-tag")); | |
| }); | |
| onApply(checked.filter(Boolean)); | |
| }, | |
| onOpen: ({backdrop, refresh}) => { | |
| const custom = backdrop.querySelector("#custom"); | |
| const addBtn = backdrop.querySelector("#addCustom"); | |
| function addCustom(){ | |
| const t = (custom.value || "").trim(); | |
| if(!t) return; | |
| const wrap = backdrop.querySelector(".checklist"); | |
| const row = document.createElement("div"); | |
| row.className = "checkrow"; | |
| row.innerHTML = `<input type="checkbox" class="tg" data-tag="${escapeAttr(t)}" checked/><div class="lbl">${escapeHtml(t)}</div>`; | |
| wrap.prepend(row); | |
| custom.value = ""; | |
| renderFilterChips(); | |
| refresh(); | |
| } | |
| addBtn.addEventListener("click", (e) => { e.preventDefault(); addCustom(); }); | |
| custom.addEventListener("keydown", (e) => { | |
| if(e.key === "Enter"){ | |
| e.preventDefault(); | |
| addCustom(); | |
| } | |
| }); | |
| custom.focus(); | |
| } | |
| }); | |
| } | |
| function draftFilled(d){ | |
| return (d.model || "").trim().length > 0 | |
| && (d.how_it_works || "").trim().length > 0 | |
| && (d.use_cases || "").trim().length > 0; | |
| } | |
| function render(){ | |
| const visibleDrafts = drafts.filter(passesFilter); | |
| const visiblePublished = state.published.filter(passesFilter); | |
| if(visibleDrafts.length === 0 && visiblePublished.length === 0){ | |
| grid.innerHTML = `<div class="empty"><b>No cards in ${escapeHtml(currentSection)}.</b><div>Click the + to add a draft, then Post to publish.</div></div>`; | |
| return; | |
| } | |
| grid.innerHTML = ""; | |
| // Drafts | |
| visibleDrafts.forEach((d) => { | |
| const card = document.createElement("div"); | |
| card.className = "card"; | |
| const modelText = (d.model || "").trim(); | |
| if(modelText) card.classList.add("has-title"); | |
| let tags = Array.isArray(d.tags) ? d.tags.map(String) : []; | |
| card.innerHTML = ` | |
| <div class="card-header"> | |
| <div class="header-left"> | |
| <input class="title-input" type="text" value="${escapeAttr(d.model)}" placeholder="Model" /> | |
| <div class="tag-bar" style="display:${modelText ? "flex" : "none"};"> | |
| <span class="tags"></span> | |
| <button class="tag-btn" type="button">Tags</button> | |
| </div> | |
| </div> | |
| <div class="hdr-right"> | |
| <span class="section-pill">${escapeHtml(normalizeSection(d.section))}</span> | |
| <span class="draft-badge">Draft</span> | |
| <button class="post-btn" type="button" ${draftFilled(d) ? "" : "disabled"}>Post</button> | |
| <button class="del-btn" type="button" title="Delete draft">×</button> | |
| </div> | |
| </div> | |
| <div class="card-main"> | |
| <div class="panel"> | |
| <label>How it Works</label> | |
| <textarea class="how">${escapeHtml(d.how_it_works)}</textarea> | |
| </div> | |
| <div class="panel"> | |
| <label>Use Cases</label> | |
| <textarea class="use">${escapeHtml(d.use_cases)}</textarea> | |
| </div> | |
| </div> | |
| <div class="card-footer"> | |
| <div class="footer-row"> | |
| <div class="section-col"> | |
| <label>Tab</label> | |
| <select class="section">${sectionOptions(d.section)}</select> | |
| </div> | |
| <div class="link-col"> | |
| <label>Link</label> | |
| <input type="text" class="link" value="${escapeAttr(d.link)}" placeholder="https://..." /> | |
| <div class="linkwrap"></div> | |
| </div> | |
| <div class="by-col"> | |
| <span>By:</span> | |
| <input class="by-input" type="text" value="${escapeAttr(d.author)}" placeholder="name" /> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| const titleEl = card.querySelector(".title-input"); | |
| const howEl = card.querySelector(".how"); | |
| const useEl = card.querySelector(".use"); | |
| const linkEl = card.querySelector(".link"); | |
| const byEl = card.querySelector(".by-input"); | |
| const sectionEl = card.querySelector(".section"); | |
| const linkWrap = card.querySelector(".linkwrap"); | |
| const tagBar = card.querySelector(".tag-bar"); | |
| const tagsWrap = card.querySelector(".tags"); | |
| const tagBtn = card.querySelector(".tag-btn"); | |
| const postBtn = card.querySelector(".post-btn"); | |
| const delBtn = card.querySelector(".del-btn"); | |
| const sectionPill = card.querySelector(".section-pill"); | |
| function updateLink(){ | |
| const url = (linkEl.value || "").trim(); | |
| linkWrap.innerHTML = url | |
| ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>` | |
| : ""; | |
| } | |
| function getDraft(){ | |
| return drafts.find(x => x.id === d.id) || null; | |
| } | |
| function setDraft(patch){ | |
| const i = drafts.findIndex(x => x.id === d.id); | |
| if(i === -1) return; | |
| drafts[i] = { ...drafts[i], ...patch }; | |
| drafts[i].section = normalizeSection(drafts[i].section); | |
| saveDrafts(); | |
| } | |
| function updateHeader(){ | |
| const hasTitle = (titleEl.value || "").trim().length > 0; | |
| card.classList.toggle("has-title", hasTitle); | |
| tagBar.style.display = hasTitle ? "flex" : "none"; | |
| sectionPill.textContent = normalizeSection(sectionEl.value); | |
| const cur = getDraft(); | |
| if(cur) postBtn.disabled = !draftFilled(cur); | |
| } | |
| function renderTags(){ | |
| tagsWrap.innerHTML = ""; | |
| tags.forEach(t => { | |
| const chip = document.createElement("span"); | |
| chip.className = "tag-chip"; | |
| chip.innerHTML = `${escapeHtml(t)} <span class="rm">×</span>`; | |
| chip.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| tags = tags.filter(x => x !== t); | |
| setDraft({ tags }); | |
| renderTags(); | |
| renderFilterChips(); | |
| updateHeader(); | |
| }); | |
| tagsWrap.appendChild(chip); | |
| }); | |
| } | |
| function scheduleDraftSave(){ | |
| const existing = saveTimers.get(d.id); | |
| if(existing) clearTimeout(existing); | |
| saveTimers.set(d.id, setTimeout(() => { | |
| setDraft({ | |
| section: normalizeSection(sectionEl.value || "Models"), | |
| model: titleEl.value || "", | |
| how_it_works: howEl.value || "", | |
| use_cases: useEl.value || "", | |
| link: linkEl.value || "", | |
| author: byEl.value || "", | |
| tags | |
| }); | |
| updateLink(); | |
| updateHeader(); | |
| renderFilterChips(); | |
| }, 150)); | |
| } | |
| tagBtn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if(!(titleEl.value || "").trim()) return; | |
| openTagsDialog({ | |
| selected: tags, | |
| onApply: (next) => { | |
| tags = next.slice(); | |
| setDraft({ tags }); | |
| renderTags(); | |
| renderFilterChips(); | |
| updateHeader(); | |
| } | |
| }); | |
| }); | |
| postBtn.addEventListener("click", () => { | |
| const cur = getDraft(); | |
| if(!cur || !draftFilled(cur)) return; | |
| openDialog({ | |
| title: "Post this card?", | |
| subtitle: "This will publish the card for everyone to see.", | |
| bodyHtml: ` | |
| <label>Password</label> | |
| <input type="password" id="pw" value="" /> | |
| `, | |
| primaryText: "Post", | |
| primaryClass: "btn-primary", | |
| canSubmit: (b) => (b.querySelector("#pw").value || "").trim().length > 0, | |
| onSubmit: async (b) => { | |
| const password = (b.querySelector("#pw").value || "").trim(); | |
| const payload = { | |
| password, | |
| section: cur.section, | |
| model: cur.model, | |
| tags: cur.tags || [], | |
| how_it_works: cur.how_it_works, | |
| use_cases: cur.use_cases, | |
| link: cur.link, | |
| author: cur.author, | |
| }; | |
| const data = await api("/api/cards", { | |
| method: "POST", | |
| body: JSON.stringify(payload), | |
| headers: { "X-Password": password }, | |
| }); | |
| drafts = drafts.filter(x => x.id !== cur.id); | |
| saveDrafts(); | |
| state.version = data.version; | |
| state.published = data.cards; | |
| showErr(""); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }, | |
| onOpen: ({backdrop, refresh}) => { | |
| const pw = backdrop.querySelector("#pw"); | |
| pw.focus(); | |
| pw.addEventListener("input", refresh); | |
| } | |
| }); | |
| }); | |
| delBtn.addEventListener("click", () => { | |
| if(!confirm("Delete this draft? (Only on your computer.)")) return; | |
| drafts = drafts.filter(x => x.id !== d.id); | |
| saveDrafts(); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }); | |
| [titleEl, howEl, useEl, linkEl, byEl].forEach(el => el.addEventListener("input", scheduleDraftSave)); | |
| sectionEl.addEventListener("change", () => { | |
| setDraft({ section: normalizeSection(sectionEl.value) }); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }); | |
| updateLink(); | |
| renderTags(); | |
| updateHeader(); | |
| grid.appendChild(card); | |
| }); | |
| // Published | |
| visiblePublished.forEach((c) => { | |
| const card = document.createElement("div"); | |
| card.className = "card"; | |
| const tags = Array.isArray(c.tags) ? c.tags.map(String) : []; | |
| const section = normalizeSection(c.section); | |
| card.innerHTML = ` | |
| <div class="card-header"> | |
| <div class="header-left"> | |
| <div class="title-text">${escapeHtml(c.model || "")}</div> | |
| <div class="tag-bar"> | |
| ${tags.map(t => `<span class="tag-chip" style="cursor:default;">${escapeHtml(t)}</span>`).join("")} | |
| </div> | |
| </div> | |
| <div class="hdr-right"> | |
| <span class="section-pill">${escapeHtml(section)}</span> | |
| <button class="edit-btn" type="button">Edit</button> | |
| <button class="del-btn" type="button" title="Delete published">×</button> | |
| </div> | |
| </div> | |
| <div class="card-main"> | |
| <div class="panel"> | |
| <label>How it Works</label> | |
| <textarea readonly>${escapeHtml(c.how_it_works || "")}</textarea> | |
| </div> | |
| <div class="panel"> | |
| <label>Use Cases</label> | |
| <textarea readonly>${escapeHtml(c.use_cases || "")}</textarea> | |
| </div> | |
| </div> | |
| <div class="card-footer"> | |
| <div class="footer-row"> | |
| <div class="section-col"> | |
| <label>Tab</label> | |
| <select disabled>${sectionOptions(section)}</select> | |
| </div> | |
| <div class="link-col"> | |
| <label>Link</label> | |
| <input readonly value="${escapeAttr(c.link || "")}" /> | |
| <div class="linkwrap"></div> | |
| </div> | |
| <div class="by-col"> | |
| <div class="by-display">${c.author ? ("By: " + escapeHtml(c.author)) : ""}</div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| const linkWrap = card.querySelector(".linkwrap"); | |
| const editBtn = card.querySelector(".edit-btn"); | |
| const delBtn = card.querySelector(".del-btn"); | |
| const url = (c.link || "").trim(); | |
| linkWrap.innerHTML = url | |
| ? `<a class="resource-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open link</a>` | |
| : ""; | |
| delBtn.addEventListener("click", () => { | |
| openDialog({ | |
| title: "Delete published card?", | |
| subtitle: "This is permanent and affects everyone viewing this Space.", | |
| bodyHtml: ` | |
| <div class="checklist" style="margin-bottom:10px;"> | |
| <div class="checkrow"> | |
| <input type="checkbox" id="c1"/> | |
| <div class="lbl">I understand this is permanent</div> | |
| </div> | |
| <div class="checkrow"> | |
| <input type="checkbox" id="c2"/> | |
| <div class="lbl">I confirm I want to delete this card</div> | |
| </div> | |
| </div> | |
| <label>Password</label> | |
| <input type="password" id="pw" value="" /> | |
| `, | |
| primaryText: "Delete", | |
| primaryClass: "btn-danger", | |
| canSubmit: (b) => b.querySelector("#c1").checked | |
| && b.querySelector("#c2").checked | |
| && (b.querySelector("#pw").value || "").trim().length > 0, | |
| onSubmit: async (b) => { | |
| const password = (b.querySelector("#pw").value || "").trim(); | |
| const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, { | |
| method: "DELETE", | |
| headers: { "X-Password": password }, | |
| }); | |
| state.version = data.version; | |
| state.published = data.cards || []; | |
| showErr(""); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }, | |
| onOpen: ({backdrop, refresh}) => { | |
| const pw = backdrop.querySelector("#pw"); | |
| const c1 = backdrop.querySelector("#c1"); | |
| const c2 = backdrop.querySelector("#c2"); | |
| pw.focus(); | |
| [pw, c1, c2].forEach(el => el.addEventListener("input", refresh)); | |
| [c1, c2].forEach(el => el.addEventListener("change", refresh)); | |
| } | |
| }); | |
| }); | |
| editBtn.addEventListener("click", () => { | |
| let tagsLocal = tags.slice(); | |
| openDialog({ | |
| title: "Edit & Republish", | |
| subtitle: "Edits go live only after you enter the password and republish.", | |
| bodyHtml: ` | |
| <div style="display:grid; gap:10px;"> | |
| <div> | |
| <label>Model</label> | |
| <input id="m" type="text" value="${escapeAttr(c.model || "")}" /> | |
| </div> | |
| <div> | |
| <label>Tab</label> | |
| <select id="s">${sectionOptions(section)}</select> | |
| </div> | |
| <div> | |
| <label>Tags</label> | |
| <div id="tags" style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;"></div> | |
| <div style="margin-top:8px;"> | |
| <button class="tag-btn" id="tagBtn" type="button">Tags</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label>How it Works</label> | |
| <textarea id="h" style="height:90px;">${escapeHtml(c.how_it_works || "")}</textarea> | |
| </div> | |
| <div> | |
| <label>Use Cases</label> | |
| <textarea id="u" style="height:90px;">${escapeHtml(c.use_cases || "")}</textarea> | |
| </div> | |
| <div class="footer-row" style="align-items:flex-end;"> | |
| <div class="link-col"> | |
| <label>Link</label> | |
| <input id="l" type="text" value="${escapeAttr(c.link || "")}" /> | |
| </div> | |
| <div class="by-col" style="padding-bottom:2px;"> | |
| <span>By:</span> | |
| <input id="a" class="by-input" type="text" value="${escapeAttr(c.author || "")}" placeholder="name" /> | |
| </div> | |
| </div> | |
| <div> | |
| <label>Password</label> | |
| <input id="pw" type="password" value="" /> | |
| </div> | |
| </div> | |
| `, | |
| primaryText: "Republish", | |
| primaryClass: "btn-primary", | |
| canSubmit: (b) => (b.querySelector("#pw").value || "").trim().length > 0 | |
| && (b.querySelector("#m").value || "").trim().length > 0 | |
| && (b.querySelector("#h").value || "").trim().length > 0 | |
| && (b.querySelector("#u").value || "").trim().length > 0, | |
| onSubmit: async (b) => { | |
| const password = (b.querySelector("#pw").value || "").trim(); | |
| const payload = { | |
| password, | |
| section: normalizeSection(b.querySelector("#s").value || "Models"), | |
| model: b.querySelector("#m").value || "", | |
| tags: tagsLocal, | |
| how_it_works: b.querySelector("#h").value || "", | |
| use_cases: b.querySelector("#u").value || "", | |
| link: b.querySelector("#l").value || "", | |
| author: b.querySelector("#a").value || "", | |
| }; | |
| const data = await api(`/api/cards/${encodeURIComponent(c.id)}`, { | |
| method: "PUT", | |
| body: JSON.stringify(payload), | |
| headers: { "X-Password": password }, | |
| }); | |
| state.version = data.version; | |
| state.published = data.cards || []; | |
| showErr(""); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }, | |
| onOpen: ({backdrop, refresh}) => { | |
| const tagsWrap = backdrop.querySelector("#tags"); | |
| const tagBtn = backdrop.querySelector("#tagBtn"); | |
| const pw = backdrop.querySelector("#pw"); | |
| function renderEditTags(){ | |
| tagsWrap.innerHTML = ""; | |
| tagsLocal.forEach(t => { | |
| const chip = document.createElement("span"); | |
| chip.className = "tag-chip"; | |
| chip.innerHTML = `${escapeHtml(t)} <span class="rm">×</span>`; | |
| chip.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| tagsLocal = tagsLocal.filter(x => x !== t); | |
| renderEditTags(); | |
| renderFilterChips(); | |
| refresh(); | |
| }); | |
| tagsWrap.appendChild(chip); | |
| }); | |
| } | |
| tagBtn.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| openTagsDialog({ | |
| selected: tagsLocal, | |
| onApply: (next) => { | |
| tagsLocal = next.slice(); | |
| renderEditTags(); | |
| renderFilterChips(); | |
| refresh(); | |
| } | |
| }); | |
| }); | |
| ["m","h","u","l","a","pw"].forEach(id => { | |
| backdrop.querySelector("#" + id).addEventListener("input", refresh); | |
| }); | |
| backdrop.querySelector("#s").addEventListener("change", refresh); | |
| pw.focus(); | |
| renderEditTags(); | |
| refresh(); | |
| } | |
| }); | |
| }); | |
| grid.appendChild(card); | |
| }); | |
| } | |
| addBtn.addEventListener("click", () => { | |
| drafts.unshift({ | |
| id: uid(), | |
| section: currentSection, | |
| model:"", | |
| tags:[], | |
| how_it_works:"", | |
| use_cases:"", | |
| link:"", | |
| author:"" | |
| }); | |
| saveDrafts(); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| }); | |
| async function refreshPublished(){ | |
| try{ | |
| const active = document.activeElement; | |
| const isTyping = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"); | |
| if(isTyping) return; | |
| const data = await api("/api/cards", { method: "GET" }); | |
| if(data.version !== state.version){ | |
| state.version = data.version; | |
| state.published = data.cards || []; | |
| showErr(""); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| } | |
| }catch(e){ | |
| showErr(String(e.message || e)); | |
| } | |
| } | |
| (async () => { | |
| saveCurrentSection(); | |
| await refreshPublished(); | |
| renderSectionTabs(); | |
| renderFilterChips(); | |
| render(); | |
| setInterval(refreshPublished, 2500); | |
| })(); | |
| })(); | |
| """ | |
| ), | |
| ) | |
| def shiny_server(input, output, session): | |
| pass | |
| app_shiny = App(app_ui, shiny_server) | |
| routes = [ | |
| Route("/api/cards", endpoint=api_cards, methods=["GET", "POST"]), | |
| Route("/api/cards/{card_id}", endpoint=api_card_id, methods=["PUT", "DELETE"]), | |
| Mount("/", app=app_shiny), | |
| ] | |
| app = Starlette(routes=routes) |