From 2b40157a0d44af906f3acc1d9bab1cd4a3235d48 Mon Sep 17 00:00:00 2001 From: Travis Herbranson Date: Mon, 25 May 2026 14:21:02 -0400 Subject: [PATCH] web: add-to-queue form on /queue too, with per-page HTMX dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Travis's original ask put the form on the "queue page" but it landed only on /dashboard. Putting it on /queue too without duplicating any logic: - _add_to_queue_form.html — extracted the form markup into a shared partial. Callers set `{% set swap_target = "#-body" %}` before including it; that's the only knob that differs between the two pages. Same POST endpoint, same field set, same flash behaviour. - _dashboard_body.html — now {% include %}s the shared partial with swap_target="#dashboard-body". Net change: a six-line include replacing the inline form. - _queue_body.html (new) — `
` wrapping the shared form (swap_target="#queue-body") + the in-flight source list (same card markup as the Sources view, with the wrap-fix already applied). Empty state copy matches the queue context. - queue.html (new) — extends base.html, page-titles "Queue", and includes _queue_body.html. /queue no longer reuses index.html. - /queue route — renders queue.html via a new _build_queue_context helper. Same in-flight filter as before (PENDING/PULLED/TRANSCRIBED). - POST /sources/add — reads HX-Current-URL (HTMX sends it on every request; falls back to Referer) and picks the response partial: /queue → _queue_body.html, anything else → _dashboard_body.html. One endpoint, one service call, two render branches — neither side reaches into the other's state. Live-verified through the rebuilt container: - GET /queue (LAN + brain.herbylab.dev) — form present. - GET /dashboard — form still present (unchanged behaviour). - GET / — form still absent (Sources view stays clean). - POST from /queue → response carries id="queue-body" (and not dashboard-body); the freshly-added row appears in the swapped list. - POST from /dashboard → response carries id="dashboard-body". - Flash scenarios from /queue: queued / duplicate / playlist 13 videos / validation error — all four render correctly. --- src/second_brain/web/app.py | 82 +++++++++++++------ .../web/templates/_add_to_queue_form.html | 67 +++++++++++++++ .../web/templates/_dashboard_body.html | 64 +-------------- .../web/templates/_queue_body.html | 62 ++++++++++++++ src/second_brain/web/templates/queue.html | 15 ++++ 5 files changed, 201 insertions(+), 89 deletions(-) create mode 100644 src/second_brain/web/templates/_add_to_queue_form.html create mode 100644 src/second_brain/web/templates/_queue_body.html create mode 100644 src/second_brain/web/templates/queue.html diff --git a/src/second_brain/web/app.py b/src/second_brain/web/app.py index 350a2aa..b6eff16 100644 --- a/src/second_brain/web/app.py +++ b/src/second_brain/web/app.py @@ -141,32 +141,41 @@ async def source_detail(request: Request, source_id: int): ) +_IN_FLIGHT_STATUSES = [ + SourceStatus.PENDING, + SourceStatus.PULLED, + SourceStatus.TRANSCRIBED, +] + + +def _build_queue_context( + sess, + *, + add_flash: Optional[str] = None, + add_error: Optional[str] = None, +) -> dict: + """Context for queue.html / _queue_body.html (in-flight source list + form).""" + sources = ( + sess.query(Source) + .filter(Source.status.in_(_IN_FLIGHT_STATUSES)) + .order_by(Source.ingested_at.asc()) + .all() + ) + return { + "sources": [_source_to_dict(s) for s in sources], + "domains": DOMAINS, + "add_flash": add_flash, + "add_error": add_error, + } + + @app.get("/queue", response_class=HTMLResponse) async def queue_view(request: Request): - """Queue view: pending and in-flight sources.""" + """Queue view: pending and in-flight sources, with an add-to-queue form.""" db = get_database() - in_flight_statuses = [SourceStatus.PENDING, SourceStatus.PULLED, SourceStatus.TRANSCRIBED] with db.session() as sess: - sources = ( - sess.query(Source) - .filter(Source.status.in_(in_flight_statuses)) - .order_by(Source.ingested_at.asc()) - .all() - ) - source_list = [_source_to_dict(s) for s in sources] - - return templates.TemplateResponse( - request, - "index.html", - { - "sources": source_list, - "domains": DOMAINS, - "statuses": [s.value for s in SourceStatus], - "selected_domain": "", - "selected_status": "", - "page_title": "Queue", - }, - ) + ctx = _build_queue_context(sess) + return templates.TemplateResponse(request, "queue.html", ctx) # --------------------------------------------------------------------------- @@ -423,12 +432,31 @@ async def sources_add( except ValueError as exc: add_error = str(exc) - with db.session() as sess: - ctx = _build_dashboard_context( - sess, add_flash=add_flash, add_error=add_error - ) + # Dispatch the response partial based on which page submitted the form. + # HTMX always sends HX-Current-URL with the request URL of the user's + # current page; we parse the path and pick the matching body partial. + # Same /sources/add endpoint, same service call — only the rendered + # region differs, so each page refreshes its own list/counts inline. + from urllib.parse import urlparse as _urlparse - return templates.TemplateResponse(request, "_dashboard_body.html", ctx) + hx_url = request.headers.get("HX-Current-URL") or request.headers.get( + "Referer", "" + ) + source_path = _urlparse(hx_url).path if hx_url else "" + + with db.session() as sess: + if source_path.rstrip("/") == "/queue": + ctx = _build_queue_context( + sess, add_flash=add_flash, add_error=add_error + ) + template_name = "_queue_body.html" + else: + ctx = _build_dashboard_context( + sess, add_flash=add_flash, add_error=add_error + ) + template_name = "_dashboard_body.html" + + return templates.TemplateResponse(request, template_name, ctx) # --------------------------------------------------------------------------- diff --git a/src/second_brain/web/templates/_add_to_queue_form.html b/src/second_brain/web/templates/_add_to_queue_form.html new file mode 100644 index 0000000..126ed3d --- /dev/null +++ b/src/second_brain/web/templates/_add_to_queue_form.html @@ -0,0 +1,67 @@ +{# Add-to-queue form. Reused by /dashboard and /queue — the only + difference between the two callers is the HTMX swap target, which the + parent template sets via `{% set swap_target = "#…-body" %}` before + including this partial. POST goes to /sources/add either way; the + route inspects HX-Current-URL to pick the right body partial in its + response. #} +
+
+

+ Add to queue +

+

video on a known host (YouTube / Vimeo) → tower; otherwise treated as an article. YouTube /playlist?list=… fans out.

+
+ + {% if add_flash %} +
+ ✓ {{ add_flash }} +
+ {% endif %} + {% if add_error %} +
+ ✗ {{ add_error }} +
+ {% endif %} + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
diff --git a/src/second_brain/web/templates/_dashboard_body.html b/src/second_brain/web/templates/_dashboard_body.html index dfc1133..39b8dc1 100644 --- a/src/second_brain/web/templates/_dashboard_body.html +++ b/src/second_brain/web/templates/_dashboard_body.html @@ -18,68 +18,8 @@ } %}
- -
-
-

- Add to queue -

-

video on a known host (YouTube / Vimeo) → tower; otherwise treated as an article

-
- - {% if add_flash %} -
- ✓ {{ add_flash }} -
- {% endif %} - {% if add_error %} -
- ✗ {{ add_error }} -
- {% endif %} - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
- - -
-
-
+ {% set swap_target = "#dashboard-body" %} + {% include "_add_to_queue_form.html" %}
diff --git a/src/second_brain/web/templates/_queue_body.html b/src/second_brain/web/templates/_queue_body.html new file mode 100644 index 0000000..35384b2 --- /dev/null +++ b/src/second_brain/web/templates/_queue_body.html @@ -0,0 +1,62 @@ +{# HTMX-replaceable queue body. POST /sources/add re-renders this when + the form was submitted from /queue (dispatched server-side via + HX-Current-URL); hx-target="#queue-body" + hx-swap="outerHTML" keeps + the wrapping div in place for the next swap. #} +{% set status_colors = { + 'pending': 'bg-gray-100 text-gray-700', + 'pulled': 'bg-blue-100 text-blue-700', + 'transcribed': 'bg-yellow-100 text-yellow-700', + 'analyzed': 'bg-orange-100 text-orange-700', + 'accepted': 'bg-green-100 text-green-700', + 'published': 'bg-purple-100 text-purple-700', + 'failed': 'bg-red-100 text-red-700' +} %} +{% set domain_colors = { + 'development': 'bg-cyan-50 text-cyan-700 border-cyan-200', + 'content': 'bg-pink-50 text-pink-700 border-pink-200', + 'business': 'bg-amber-50 text-amber-700 border-amber-200', + 'homelab': 'bg-teal-50 text-teal-700 border-teal-200' +} %} +
+ + {% set swap_target = "#queue-body" %} + {% include "_add_to_queue_form.html" %} + + + {% if sources %} + + {% else %} +
+

Queue is empty.

+

Add a URL above to get started.

+
+ {% endif %} +
diff --git a/src/second_brain/web/templates/queue.html b/src/second_brain/web/templates/queue.html new file mode 100644 index 0000000..faa8806 --- /dev/null +++ b/src/second_brain/web/templates/queue.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Queue — second-brain{% endblock %} + +{% block content %} +
+

+ Queue + ({{ sources|length }}) +

+ pending → pulled → transcribed +
+ +{% include "_queue_body.html" %} +{% endblock %}