📄 markdown_hosting.py
/home/palash/git/local-ai/markdown_hosting.py
Language: py • Lines: 700
import os
import json
import html
import shutil
import markdown
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import HTMLResponse, FileResponse
from urllib.parse import quote

from server.dotenv import load_dotenv

load_dotenv()

app = FastAPI()

BASE_STORIES_DIR = "./stories"

# Directory roots resolved from environment variables.
# Hierarchy: everyone -> free dir, premium +1 dir, admin +1 more dir.
# PREMIUM and ADMIN dirs are REQUIRED — markdown hosting fails fast if missing.
_FREE_DIR = os.path.expanduser(os.getenv("STORIES_FREE_DIR", os.path.expanduser("~/local-ai-files/stories")))
_PREMIUM_DIR = os.path.expanduser(os.getenv("STORIES_PREMIUM_DIR", ""))
_ADMIN_DIR = os.path.expanduser(os.getenv("STORIES_ADMIN_DIR", ""))

for _var, _val in (("STORIES_PREMIUM_DIR", _PREMIUM_DIR), ("STORIES_ADMIN_DIR", _ADMIN_DIR)):
    if not _val:
        raise RuntimeError(
            f"markdown_hosting.py cannot start: required environment variable "
            f"{_var} is not set."
        )

# Each collection requires a minimum role level to access.
ROLE_LEVEL = {
    "guest": 0,
    "free": 0,
    "user": 0,
    "premium": 1,
    "admin": 2,
}

COLLECTION_RULES = {
    "free_stories": {"path": _FREE_DIR, "min_level": 0},     # everyone
    "premium_stories": {"path": _PREMIUM_DIR, "min_level": 1},  # free + premium
    "admin_stories": {"path": _ADMIN_DIR, "min_level": 2},    # free + premium + admin
}


# --- Auth & RBAC Helpers (shared Authentik-backed identity) ---

def get_current_user(request: Request) -> str | None:
    """Username authenticated through nginx's auth_request (X-Authentik-*).

    Direct localhost callers may present ``Authorization: Bearer <jwt>``; that
    is verified against Authentik's JWKS (see server/auth.py). Returns None
    when no identity is present.
    """
    from server.auth import get_current_user as _get_current_user
    return _get_current_user(request.headers)


def get_current_role(request: Request) -> str:
    """Role (free/premium/admin) derived from Authentik group membership."""
    from server.auth import get_identity as _get_identity
    identity = _get_identity(request.headers)
    return identity["role"] if identity else "free"


def user_role_level(username: str | None, role: str | None = None) -> int:
    """Map a username (or resolved role) to its hierarchy level (0=guest ... 2=admin)."""
    if role is None:
        if not username:
            return ROLE_LEVEL["guest"]
        return ROLE_LEVEL.get(get_user_role(username), ROLE_LEVEL["free"])
    return ROLE_LEVEL.get(role, ROLE_LEVEL["free"])


def get_user_role(username: str) -> str:
    """Resolve role from the shared identity provider's group membership.

    Kept for backward compatibility; the chat server derives role from
    X-Authentik-Groups via server/auth.py.
    """
    from server.auth import role_from_groups
    return role_from_groups(_stories_role_groups_for(username))


def _stories_role_groups_for(username: str) -> list:
    """Best-effort group mapping for a username seen without claim headers.

    Legacy fallback so direct localhost requests (no nginx auth_request) still
    get a sensible role. Once nginx fronts the service this path is never hit.
    """
    if username in {"palash"}:
        return ["admin"]
    if username in {"totan"}:
        return ["premium"]
    return ["free"]


def enforce_rbac(collection_folder: str, request: Request | None = None, username: str | None = None):
    """Checks the user's role against the collection's minimum required level."""
    rule = COLLECTION_RULES.get(collection_folder)
    if not rule:
        raise HTTPException(status_code=404, detail="Collection not found")

    if request is not None:
        level = user_role_level(None, get_current_role(request))
        authed = bool(get_current_user(request))
    else:
        authed = bool(username)
        level = user_role_level(username)

    min_level = rule["min_level"]
    if level < min_level:
        if not authed and min_level > 0:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Authentication required.",
            )
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Access Denied: higher subscription/role required for this story collection.",
        )


def error_page(status_code: int, detail: str) -> str:
    """Render a styled HTML error page for browser navigation.

    API and live-polling endpoints keep their JSON error responses (see
    ``_http_exception_html_handler``); anything a user would navigate to in the
    browser gets a proper page instead of raw JSON. Unauthenticated browser
    traffic never reaches here anonymously in production — nginx runs
    ``auth_request`` against the Authentik proxy outpost, so a 401 means the
    Authentik session simply needs to be established.
    """
    titles = {
        401: "Authentication Required",
        403: "Access Denied",
        404: "Not Found",
    }
    title = titles.get(status_code, f"Error {status_code}")
    if status_code == 401:
        message = "Please log in to view this story collection."
    else:
        message = html.escape(str(detail))
    login_block = """
    <span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
    """
    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{title}</title>
        <style>
            * {{ box-sizing: border-box; }}
            html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
            body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
            h1 {{ color: #c44; }}
            a.back {{ color: #666; text-decoration: none; }}
            .topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }}
            .topbar .login {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }}
            .topbar .login.hidden {{ display: none; }}
            .topbar .login-toggle a {{ color: #06c; font-weight: bold; }}
            .topbar input {{ padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }}
            .topbar button {{ background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }}
            .topbar button:hover {{ background: #eee; }}
            .topbar button.primary {{ background: #06c; color: #fff; border-color: #06c; }}
            .topbar button.primary:hover {{ background: #0577e6; }}
            .topbar #login-msg {{ color: #c44; font-size: 12px; width: 100%; }}
            @media (max-width: 600px) {{
                body {{ padding: 12px; font-size: 19px; }}
                .topbar {{ flex-direction: column; align-items: stretch; }}
                .topbar .login {{ flex-direction: column; align-items: stretch; }}
                .topbar input {{ width: 100%; }}
            }}
            @media (prefers-color-scheme: dark) {{
                body {{ background: #16181d; color: #e6e6e6; }}
                h1 {{ color: #ff8080; }}
                a.back {{ color: #999; }}
                .topbar .login-toggle a {{ color: #7ab8ff; }}
                .topbar input {{ background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }}
                .topbar input::placeholder {{ color: #888; }}
                .topbar button {{ background: #1f232b; border-color: #555; color: #cfcfcf; }}
                .topbar button:hover {{ background: #262b34; }}
                .topbar button.primary {{ background: #3a7ee0; border-color: #3a7ee0; color: #fff; }}
                .topbar button.primary:hover {{ background: #4a8cec; }}
                .topbar #login-msg {{ color: #ff8080; }}
            }}
        </style>
    </head>
    <body>
        <nav class="topbar">
            <a href="/" class="back">← Back to Collections</a>
            {login_block}
        </nav>
        <h1>{title}</h1>
        <p>{message}</p>
    </body>
    </html>
    """


@app.exception_handler(HTTPException)
async def _http_exception_html_handler(request: Request, exc: HTTPException):
    """JSON errors for API/live-poll endpoints, HTML pages for browser routes."""
    path = request.url.path
    if path.startswith("/api/") or path.endswith("/content"):
        return await http_exception_handler(request, exc)
    return HTMLResponse(
        status_code=exc.status_code,
        content=error_page(exc.status_code, exc.detail),
    )


# --- Authentication Endpoints ---

# Browser authentication is handled entirely by nginx's auth_request against
# the Authentik proxy outpost (/sso/...). The endpoints that previously issued
# self-managed X-Auth-Token cookies no longer exist: identity comes from the
# X-Authentik-* claim headers nginx forwards upstream.


# --- Dynamic Story Engine & Media Router ---

def pick_story_md(folder_path):
    """Prefer the editor's revised file (story_rN_ts.edited.md) over the original."""
    mds = [f for f in os.listdir(folder_path) if f.endswith(".md")]
    if not mds:
        return None
    edited = [f for f in mds if f.endswith(".edited.md")]
    return os.path.join(folder_path, (edited or mds)[0])


def story_moderation(folder_path):
    """Return the moderator verdict dict for a story, or None."""
    for f in os.listdir(folder_path):
        if f.endswith(".moderation.json"):
            try:
                with open(os.path.join(folder_path, f), encoding="utf-8") as fh:
                    return json.load(fh)
            except (OSError, json.JSONDecodeError):
                return None
    return None


def moderation_badge(mod):
    """HTML snippet showing the GREEN/RED verdict, or empty string.

    For RED verdicts, includes the moderator's reason as a tap/hover tooltip
    (see .mod-badge CSS/JS shared by both pages).
    """
    if not mod:
        return ""
    v = mod.get("verdict", "")
    color = "#2a7" if v == "GREEN" else ("#c44" if v == "RED" else "#888")
    if v == "RED":
        reason = html.escape(mod.get("reasons") or mod.get("reason") or "No reason provided.")
        return (
            f' <span class="mod-badge" tabindex="0" data-reason="{reason}" '
            f'style="color:{color}; font-size:11px; font-family:sans-serif; '
            f'cursor:pointer; border-bottom:1px dotted {color};">({v})</span>'
        )
    return (
        f' <span style="color:{color}; font-size:11px; font-family:sans-serif;">'
        f"({v})</span>"
    )


def list_collection_stories(root: str):
    """Return [(genre_label | None, story_id)] for a collection root.

    Legacy flat story folders (md directly inside root) are reported with
    genre None; genre folders contain story subdirectories.
    """
    entries = sorted(
        os.listdir(root),
        key=lambda e: os.path.getmtime(os.path.join(root, e)),
        reverse=True,
    )
    items = []
    for entry in entries:
        full = os.path.join(root, entry)
        if not os.path.isdir(full):
            continue
        if any(f.endswith(".md") for f in os.listdir(full)):
            items.append((None, entry))
            continue
        for sub in sorted(os.listdir(full), reverse=True):
            subfull = os.path.join(full, sub)
            if os.path.isdir(subfull) and any(
                f.endswith(".md") for f in os.listdir(subfull)
            ):
                items.append((entry, os.path.join(entry, sub)))
    return items


@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    """Collections index listing available story collections and their stories."""
    username = get_current_user(request)
    role = get_current_role(request)
    user_level = user_role_level(username, role)
    cards = []
    for name, rule in COLLECTION_RULES.items():
        if rule["min_level"] > user_level:
            continue
        root = rule["path"]
        if not os.path.isdir(root):
            continue
        items = list_collection_stories(root)
        if not items:
            continue
        grouped = {}
        for genre, sid in items:
            grouped.setdefault(genre, []).append(sid)
        sections = []
        for genre, sids in grouped.items():
            heading = f"<h3>{genre.replace('_', ' ').title()}</h3>" if genre else ""
            lis = []
            for sid in sids:
                badge = moderation_badge(story_moderation(os.path.join(root, sid)))
                
                # Safely quote path segments for URLs and escape HTML text display
                encoded_sid = "/".join(quote(part) for part in sid.split("/"))
                display_name = html.escape(sid.split("/")[-1])
                
                lis.append(
                    f'<li><a href="/story/{name}/{encoded_sid}">{display_name}</a>{badge}</li>'
                )
            sections.append(heading + "<ul>" + "".join(lis) + "</ul>")
        cards.append(
            f"<h2>{name.replace('_', ' ').title()}</h2>" + "".join(sections)
        )
    body = "".join(cards) or "<p>No story collections found yet.</p>"
    if username:
        auth_html = f"""
        <span class="logged">Logged in as <strong>{username}</strong> ({role})</span>
        <button id="logout-btn">Log out</button>
        """
    else:
        auth_html = """
        <span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
        """
    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Story Collections</title>
        <style>
            * {{ box-sizing: border-box; }}
            html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
            body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
            h1, h2, h3 {{ color: #333; line-height: 1.3; }}
            ul {{ margin: 0 0 1.2em; padding-left: 1.4em; }}
            li {{ margin-bottom: 0.6em; }}
            a {{ color: #06c; text-decoration: none; }}
            .topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; font-family: sans-serif; font-size: 14px; }}
            .topbar .logged {{ margin: 0; color: #555; }}
            .topbar .login {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }}
            .topbar .login.hidden {{ display: none; }}
            .topbar .login-toggle a {{ color: #06c; font-weight: bold; }}
            .topbar input {{ padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }}
            .topbar button {{ background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }}
            .topbar button:hover {{ background: #eee; }}
            .topbar button.primary {{ background: #06c; color: #fff; border-color: #06c; }}
            .topbar button.primary:hover {{ background: #0577e6; }}
            .topbar #login-msg {{ color: #c44; font-size: 12px; width: 100%; }}
            .mod-badge {{ position: relative; }}
            .mod-badge:hover::after, .mod-badge.show-tip::after {{
                content: attr(data-reason);
                position: absolute; left: 0; top: 100%; margin-top: 4px;
                background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
                font-size: 12px; line-height: 1.4; white-space: normal;
                width: max-content; max-width: 240px; z-index: 10;
            }}
            @media (max-width: 600px) {{
                body {{ padding: 12px; font-size: 19px; }}
                .topbar {{ flex-direction: column; align-items: stretch; }}
                .topbar .login {{ flex-direction: column; align-items: stretch; }}
                .topbar input {{ width: 100%; }}
            }}
            @media (prefers-color-scheme: dark) {{
                body {{ background: #16181d; color: #e6e6e6; }}
                h1, h2, h3 {{ color: #f0f0f0; }}
                a {{ color: #7ab8ff; }}
                .topbar .logged {{ color: #aaa; }}
                .topbar .login-toggle a {{ color: #7ab8ff; }}
                .topbar input {{ background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }}
                .topbar input::placeholder {{ color: #888; }}
                .topbar button {{ background: #1f232b; border-color: #555; color: #cfcfcf; }}
                .topbar button:hover {{ background: #262b34; }}
                .topbar button.primary {{ background: #3a7ee0; border-color: #3a7ee0; color: #fff; }}
                .topbar button.primary:hover {{ background: #4a8cec; }}
                .topbar #login-msg {{ color: #ff8080; }}
            }}
        </style>
    </head>
    <body>
        <nav class="topbar">{auth_html}</nav>
        <h1>Story Collections</h1>
        {body}
        <script>
            const logoutBtn = document.getElementById('logout-btn');
            if (logoutBtn) {{
                logoutBtn.addEventListener('click', () => {{
                    window.location.href = '/outpost.goauthentik.io/sign_out?rd=%2Fstories%2F';
                }});
            }}
            // Tap-to-toggle tooltip for moderation badges (title= doesn't work on mobile touch).
            document.querySelectorAll('.mod-badge').forEach(el => {{
                el.addEventListener('click', e => {{
                    e.stopPropagation();
                    document.querySelectorAll('.mod-badge.show-tip').forEach(o => {{
                        if (o !== el) o.classList.remove('show-tip');
                    }});
                    el.classList.toggle('show-tip');
                }});
            }});
            document.addEventListener('click', () => {{
                document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
            }});
        </script>
    </body>
    </html>
    """
@app.get("/media/{collection}/{story_id:path}/{filename}")
async def serve_story_image(
    collection: str, 
    story_id: str, 
    filename: str, 
    request: Request
):
    """Serves media dynamically while checking the authenticated session."""
    enforce_rbac(collection, request=request)
    
    root = COLLECTION_RULES[collection]["path"]
    file_path = os.path.join(root, story_id, filename)
    if not os.path.exists(file_path):
        raise HTTPException(status_code=404, detail="Image not found")
        
    return FileResponse(file_path)


def render_story_html(collection: str, story_id: str, content: str) -> str:
    """Render story markdown and rewrite image srcs to the authenticated media route.

    pymdownx.arithmatex (generic mode) leaves $...$ / $$...$$ math untouched
    but wraps it in <span class="arithmatex"> / <div class="arithmatex">
    so the KaTeX auto-render script loaded on the story page can find and
    typeset it client-side.
    """
    html_content = markdown.markdown(
        content,
        extensions=['extra', 'tables', 'fenced_code', 'pymdownx.arithmatex'],
        extension_configs={'pymdownx.arithmatex': {'generic': True}},
    )
    html_content = html_content.replace(
        '<table>', '<div class="table-wrap"><table>'
    ).replace('</table>', '</table></div>')
    return html_content.replace('src="', f'src="/media/{collection}/{story_id}/')


@app.get("/story/{collection}/{story_id:path}/content")
async def story_content(
    collection: str, 
    story_id: str, 
    request: Request
):
    """Returns the current rendered story HTML for live polling."""
    enforce_rbac(collection, request=request)

    folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
    if not os.path.exists(folder_path):
        raise HTTPException(status_code=404, detail="Story folder not found")

    md_file = pick_story_md(folder_path)
    if not md_file:
        raise HTTPException(status_code=404, detail="No markdown file found in story directory")

    with open(md_file, 'r', encoding='utf-8') as f:
        content = f.read()

    return {"html": render_story_html(collection, story_id, content)}


@app.delete("/story/{collection}/{story_id:path}")
async def delete_story(
    collection: str, 
    story_id: str, 
    request: Request
):
    """Deletes the story folder (markdown + images). Admin role required."""
    enforce_rbac(collection, request=request)

    if get_current_role(request) != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Admin role required to delete stories.",
        )

    folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
    if not os.path.exists(folder_path):
        raise HTTPException(status_code=404, detail="Story folder not found")

    shutil.rmtree(folder_path, ignore_errors=True)
    if os.path.exists(folder_path):
        raise HTTPException(status_code=500, detail="Failed to delete story folder")
    return {"ok": True, "deleted": story_id}


@app.get("/story/{collection}/{story_id:path}", response_class=HTMLResponse)
async def read_story(
    collection: str, 
    story_id: str, 
    request: Request
):
    """Reads story Markdown dynamically and enforces access controls."""
    enforce_rbac(collection, request=request)
    
    folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
    if not os.path.exists(folder_path):
        raise HTTPException(status_code=404, detail="Story folder not found")
        
    md_file = pick_story_md(folder_path)
    if not md_file:
        raise HTTPException(status_code=404, detail="No markdown file found in story directory")
        
    with open(md_file, 'r', encoding='utf-8') as f:
        content = f.read()

    html_content = render_story_html(collection, story_id, content)
    is_admin = get_current_role(request) == "admin"

    verdict_html = ""
    mod = story_moderation(folder_path)
    if mod:
        v = mod.get("verdict", "")
        color = "#2a7" if v == "GREEN" else ("#c44" if v == "RED" else "#888")
        if v == "RED":
            reason = html.escape(mod.get("reasons") or mod.get("reason") or "No reason provided.")
            verdict_html = (
                f'<div class="mod-badge" tabindex="0" data-reason="{reason}" '
                f'style="font-family:sans-serif; color:{color}; font-size:13px; '
                f'margin-bottom:12px; display:inline-block; cursor:pointer; '
                f'border-bottom:1px dotted {color};">Moderation: {v} (tap for reason)</div>'
            )
        else:
            verdict_html = (
                f'<div style="font-family:sans-serif; color:{color}; font-size:13px; '
                f'margin-bottom:12px;">Moderation: {v}</div>'
            )

    delete_button_html = '<button id="delete-btn">Delete story</button>' if is_admin else ""

    # Escaped parameters for safe JavaScript injection and HTTP URL generation
    story_id_js = json.dumps(story_id)
    encoded_collection = quote(collection)
    encoded_story_path = "/".join(quote(part) for part in story_id.split("/"))

    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{html.escape(story_id.split('/')[-1].replace('-', ' ').title())}</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
        <style>
            * {{ box-sizing: border-box; }}
            html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
            body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
            article {{ overflow-wrap: break-word; }}
            article p, article li {{ font-size: 1em; }}
            img {{ max-width: 100%; height: auto; border-radius: 8px; margin: 20px 0; display: block; }}
            .topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }}
            a.back {{ color: #666; text-decoration: none; }}
            .topbar button {{ background: none; border: 1px solid #c44; color: #c44; border-radius: 6px; padding: 4px 12px; cursor: pointer; font-family: sans-serif; font-size: 14px; }}
            .topbar button:hover {{ background: #fceaea; }}
            blockquote {{ border-left: 4px solid #ddd; margin: 0 0 1em; padding: 0 0 0 16px; color: #555; }}
            code {{ background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.85em; }}
            pre {{ background: #f0f0f0; padding: 12px; border-radius: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; }}
            pre code {{ background: none; padding: 0; font-size: 0.85em; }}
            .table-wrap {{ overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 1em 0; }}
            table {{ border-collapse: collapse; font-size: 0.9em; }}
            th, td {{ border: 1px solid #ccc; padding: 6px 10px; }}
            .mod-badge {{ position: relative; }}
            .mod-badge:hover::after, .mod-badge.show-tip::after {{
                content: attr(data-reason);
                position: absolute; left: 0; top: 100%; margin-top: 4px;
                background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
                font-size: 12px; line-height: 1.4; white-space: normal;
                width: max-content; max-width: 280px; z-index: 10;
            }}
            @media (max-width: 600px) {{
                body {{ padding: 12px; font-size: 19px; }}
                .topbar {{ flex-direction: column; align-items: stretch; }}
                .topbar button {{ width: 100%; }}
            }}
            @media (prefers-color-scheme: dark) {{
                body {{ background: #16181d; color: #e6e6e6; }}
                a.back {{ color: #999; }}
                .topbar button {{ border-color: #e05a5a; color: #ff7a7a; }}
                .topbar button:hover {{ background: #2a1c1c; }}
                blockquote {{ border-left-color: #444; color: #aaa; }}
                code {{ background: #2a2e37; }}
                pre {{ background: #2a2e37; }}
                th, td {{ border-color: #3a3f4a; }}
            }}
        </style>
    </head>
    <body>
        <nav class="topbar">
            <a href="/" class="back">← Back to Collections</a>
            {delete_button_html}
        </nav>
        {verdict_html}
        <article id="story-article">{html_content}</article>
        <script>
            const storyId = {story_id_js};
            const article = document.getElementById('story-article');
            let lastHtml = article.innerHTML;

            function typesetMath() {{
                if (typeof renderMathInElement !== 'function') {{
                    setTimeout(typesetMath, 100);
                    return;
                }}
                renderMathInElement(article, {{
                    delimiters: [
                        {{left: '\\\\[', right: '\\\\]', display: true}},
                        {{left: '\\\\(', right: '\\\\)', display: false}},
                        {{left: '$$', right: '$$', display: true}},
                        {{left: '$', right: '$', display: false}}
                    ],
                    throwOnError: false
                }});
            }}
            typesetMath();

            async function poll() {{
                try {{
                    const r = await fetch('/story/{encoded_collection}/{encoded_story_path}/content');
                    if (!r.ok) return;
                    const data = await r.json();
                    const newHtml = data.html;
                    if (newHtml !== lastHtml) {{
                        if (newHtml.startsWith(lastHtml)) {{
                            const nearBottom = window.innerHeight + window.scrollY > document.body.scrollHeight - 150;
                            article.insertAdjacentHTML('beforeend', newHtml.slice(lastHtml.length));
                            if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
                        }} else {{
                            article.innerHTML = newHtml;
                        }}
                        lastHtml = newHtml;
                        typesetMath();
                    }}
                }} catch (e) {{}}
                setTimeout(poll, 3000);
            }}
            poll();

            const deleteBtn = document.getElementById('delete-btn');
            if (deleteBtn) {{
                deleteBtn.addEventListener('click', async () => {{
                    if (!confirm(`Delete story "${{storyId}}" and all its images?`)) return;
                    try {{
                        const r = await fetch('/story/{encoded_collection}/{encoded_story_path}', {{ method: 'DELETE' }});
                        if (r.ok) {{
                            window.location.href = '/';
                        }} else {{
                            const d = await r.json();
                            alert('Delete failed: ' + (d.detail || r.status));
                        }}
                    }} catch (e) {{
                        alert('Delete failed: ' + e.message);
                    }}
                }});
            }}

            // Tap-to-toggle tooltip for the moderation reason (title= doesn't work on mobile touch).
            document.querySelectorAll('.mod-badge').forEach(el => {{
                el.addEventListener('click', e => {{
                    e.stopPropagation();
                    el.classList.toggle('show-tip');
                }});
            }});
            document.addEventListener('click', () => {{
                document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
            }});
        </script>
    </body>
    </html>
    """