# 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 "/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 => ( `` )).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 ``; }).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 `${escapeHtml(t)}`; }).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 = ` `; 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 `
${escapeHtml(t)}
`; }).join(""); openDialog({ title: "Tags", subtitle: "Check all that apply.", bodyHtml: `
${listHtml}
`, 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 = `
${escapeHtml(t)}
`; 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 = `
No cards in ${escapeHtml(currentSection)}.
Click the + to add a draft, then Post to publish.
`; 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 = `
${escapeHtml(normalizeSection(d.section))} Draft
`; 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 ? `Open link` : ""; } 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)} ×`; 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: ` `, 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 = `
${escapeHtml(c.model || "")}
${tags.map(t => `${escapeHtml(t)}`).join("")}
${escapeHtml(section)}
`; 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 ? `Open link` : ""; delBtn.addEventListener("click", () => { openDialog({ title: "Delete published card?", subtitle: "This is permanent and affects everyone viewing this Space.", bodyHtml: `
I understand this is permanent
I confirm I want to delete this card
`, 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: `
`, 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)} ×`; 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)