Name the book on the speakers page and add a project switcher

Reported: the speakers page showed 51 needing review in Chrome on Windows
and 20 in Safari on the Mac, and showed no names starting with D including
"Deal Hunter" - despite several reloads.

Neither browser was wrong. The active project is a per-browser cookie, so
each browser was on a different book and the two lists are unrelated:

  Last Dwarf                 91 speakers, 51 need checking, 0 D-names
  DealHunterShip_4.0e        61 speakers, 19 need checking, includes
                             Deal Hunter, Dealer, Distorted Voice,
                             Duchess Victoria, Duty Nurse

"Deal Hunter" is the ship AI in the Deal Hunter book and is not a Last
Dwarf character, so its absence there was correct. The page gave no hint
that the book was even part of what was being displayed.

- The speakers page now shows a "Reviewing <project>" banner next to the
  counts, so the number and the book it belongs to are read together.
- Added a project switcher directly on that page; previously changing
  project meant navigating via the dashboard.
- activate_project accepts ?next=, restricted to same-site relative paths
  so the switcher can stay put. Verified //evil.com and https://evil.com
  both fall back to "/".
- list_projects registered as a template global, following the existing
  active_project pattern; it degrades to [] on any error.

routes.py stays at its 76 pre-existing ruff errors. Suite: 684 passed, 8
pre-existing.
This commit is contained in:
B.D. Murphy
2026-10-10 19:45:47 -07:00
parent 2fda85f9d3
commit 7dde69ebb3
2 changed files with 52 additions and 3 deletions

View File

@@ -13,7 +13,7 @@ from typing import Any
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Form, Request
from fastapi import APIRouter, Form, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
@@ -54,6 +54,25 @@ def _active_project_for_template(request: Request) -> dict:
templates.env.globals["active_project"] = _active_project_for_template
def _list_projects_for_template() -> list[dict]:
"""Every project, so the speakers page can offer a switcher.
The active project is a per-browser cookie; without a visible switcher a
reviewer comparing two browsers had no way to tell they were looking at
two different books.
"""
from umbraaudio.config import list_projects
try:
return list_projects()
except Exception:
return []
templates.env.globals["list_projects"] = _list_projects_for_template
def _voice_maintenance_for_template() -> dict | None:
"""Expose the active voice-maintenance marker (or None) to templates."""
@@ -553,7 +572,11 @@ async def project_new_form(request: Request):
@router.post("/api/projects/activate/{slug}")
async def activate_project(request: Request, slug: str):
async def activate_project(
request: Request,
slug: str,
next_url: str = Query("/", alias="next"),
):
from fastapi.responses import RedirectResponse
from umbraaudio.config import load_config
@@ -562,7 +585,12 @@ async def activate_project(request: Request, slug: str):
if not cfg.data_dir.exists():
return JSONResponse({"error": f"Project '{slug}' not found"}, status_code=404)
response = RedirectResponse(url="/", status_code=303)
# Only same-site relative paths, so this cannot be turned into an open
# redirect. Lets the project switcher stay on the page the reviewer is on
# instead of dumping them back at the dashboard.
if not next_url.startswith("/") or next_url.startswith("//"):
next_url = "/"
response = RedirectResponse(url=next_url, status_code=303)
response.set_cookie("active_project", slug, max_age=86400 * 365)
return response

View File

@@ -1,5 +1,26 @@
{% set designations = speaker_designations(request) %}
{% set _ap = active_project(request) %}
<div class="card">
{# The active project is a per-browser cookie. Two browsers could sit on
different books and their speaker lists are completely unrelated, which
made "51 need checking here, 20 there" look like a bug. Name the book
on the page itself, next to the counts it qualifies. #}
<div style="display:flex; align-items:center; gap:0.6rem; margin-bottom:0.75rem;
padding:0.5rem 0.7rem; background:#1a1a2e; border-left:4px solid #7c3aed; border-radius:6px">
<span style="color:#94a3b8; font-size:0.8rem">Reviewing</span>
<strong style="color:#e0e7ff; font-size:1rem">{{ _ap.name }}</strong>
<span style="color:#64748b; font-size:0.76rem">— this list is only this book; another browser or tab may be on a different one</span>
<form method="post" action="/api/projects/activate/{{ _ap.slug }}?next=/speakers"
style="margin-left:auto; display:flex; gap:0.35rem; align-items:center">
<select name="ignored" onchange="this.form.submit()"
style="background:#0f3460; color:#e0e0e0; border:1px solid #374151; border-radius:4px; padding:0.25rem">
{% for p in list_projects() %}
<option value="{{ p.slug }}" {% if p.slug == _ap.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="btn" style="font-size:0.72rem">Switch</button></noscript>
</form>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<h3 style="margin:0">
Speakers ({{ speakers|length }})