web: add-to-queue form on /queue too, with per-page HTMX dispatch

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 = "#<id>-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) — `<div id="queue-body">` 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.
This commit is contained in:
Travis Herbranson 2026-05-25 14:21:02 -04:00
parent a475893403
commit 2b40157a0d
5 changed files with 201 additions and 89 deletions

View File

@ -141,32 +141,41 @@ async def source_detail(request: Request, source_id: int):
) )
@app.get("/queue", response_class=HTMLResponse) _IN_FLIGHT_STATUSES = [
async def queue_view(request: Request): SourceStatus.PENDING,
"""Queue view: pending and in-flight sources.""" SourceStatus.PULLED,
db = get_database() SourceStatus.TRANSCRIBED,
in_flight_statuses = [SourceStatus.PENDING, SourceStatus.PULLED, SourceStatus.TRANSCRIBED] ]
with db.session() as sess:
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 = ( sources = (
sess.query(Source) sess.query(Source)
.filter(Source.status.in_(in_flight_statuses)) .filter(Source.status.in_(_IN_FLIGHT_STATUSES))
.order_by(Source.ingested_at.asc()) .order_by(Source.ingested_at.asc())
.all() .all()
) )
source_list = [_source_to_dict(s) for s in sources] return {
"sources": [_source_to_dict(s) for s in sources],
return templates.TemplateResponse(
request,
"index.html",
{
"sources": source_list,
"domains": DOMAINS, "domains": DOMAINS,
"statuses": [s.value for s in SourceStatus], "add_flash": add_flash,
"selected_domain": "", "add_error": add_error,
"selected_status": "", }
"page_title": "Queue",
},
) @app.get("/queue", response_class=HTMLResponse)
async def queue_view(request: Request):
"""Queue view: pending and in-flight sources, with an add-to-queue form."""
db = get_database()
with db.session() as sess:
ctx = _build_queue_context(sess)
return templates.TemplateResponse(request, "queue.html", ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -423,12 +432,31 @@ async def sources_add(
except ValueError as exc: except ValueError as exc:
add_error = str(exc) add_error = str(exc)
# 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
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: 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( ctx = _build_dashboard_context(
sess, add_flash=add_flash, add_error=add_error sess, add_flash=add_flash, add_error=add_error
) )
template_name = "_dashboard_body.html"
return templates.TemplateResponse(request, "_dashboard_body.html", ctx) return templates.TemplateResponse(request, template_name, ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -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. #}
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Add to queue
</h3>
<p class="text-xs text-gray-400">video on a known host (YouTube / Vimeo) → tower; otherwise treated as an article. YouTube /playlist?list=… fans out.</p>
</div>
{% if add_flash %}
<div class="mb-3 px-3 py-2 rounded bg-green-50 border border-green-200 text-green-700 text-sm">
✓ {{ add_flash }}
</div>
{% endif %}
{% if add_error %}
<div class="mb-3 px-3 py-2 rounded bg-red-50 border border-red-200 text-red-700 text-sm">
✗ {{ add_error }}
</div>
{% endif %}
<form hx-post="/sources/add"
hx-target="{{ swap_target }}"
hx-swap="outerHTML"
class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
<div class="md:col-span-6">
<label class="block text-xs font-medium text-gray-600 mb-1">URL</label>
<input type="url" name="url" required autofocus
placeholder="https://…"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
</div>
<div class="md:col-span-2">
<label class="block text-xs font-medium text-gray-600 mb-1">Domain</label>
<select name="domain"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
{% for d in domains %}
<option value="{{ d }}" {% if d == "development" %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
</div>
<div class="md:col-span-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Title <span class="text-gray-400">(optional)</span></label>
<input type="text" name="title"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
</div>
<div class="md:col-span-1">
<button type="submit"
class="w-full px-4 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 transition-colors">
Add
</button>
</div>
<div class="md:col-span-12">
<label class="block text-xs font-medium text-gray-600 mb-1">Focus <span class="text-gray-400">(optional — narrows the extraction prompt)</span></label>
<input type="text" name="focus"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
</div>
</form>
</section>

View File

@ -18,68 +18,8 @@
} %} } %}
<div id="dashboard-body"> <div id="dashboard-body">
<!-- Add source --> {% set swap_target = "#dashboard-body" %}
<section class="bg-white rounded-lg shadow-sm p-5 mb-6"> {% include "_add_to_queue_form.html" %}
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Add to queue
</h3>
<p class="text-xs text-gray-400">video on a known host (YouTube / Vimeo) → tower; otherwise treated as an article</p>
</div>
{% if add_flash %}
<div class="mb-3 px-3 py-2 rounded bg-green-50 border border-green-200 text-green-700 text-sm">
✓ {{ add_flash }}
</div>
{% endif %}
{% if add_error %}
<div class="mb-3 px-3 py-2 rounded bg-red-50 border border-red-200 text-red-700 text-sm">
✗ {{ add_error }}
</div>
{% endif %}
<form hx-post="/sources/add"
hx-target="#dashboard-body"
hx-swap="outerHTML"
class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
<div class="md:col-span-6">
<label class="block text-xs font-medium text-gray-600 mb-1">URL</label>
<input type="url" name="url" required autofocus
placeholder="https://…"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
</div>
<div class="md:col-span-2">
<label class="block text-xs font-medium text-gray-600 mb-1">Domain</label>
<select name="domain"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
{% for d in domains %}
<option value="{{ d }}" {% if d == "development" %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
</div>
<div class="md:col-span-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Title <span class="text-gray-400">(optional)</span></label>
<input type="text" name="title"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
</div>
<div class="md:col-span-1">
<button type="submit"
class="w-full px-4 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded hover:bg-indigo-700 transition-colors">
Add
</button>
</div>
<div class="md:col-span-12">
<label class="block text-xs font-medium text-gray-600 mb-1">Focus <span class="text-gray-400">(optional — narrows the extraction prompt)</span></label>
<input type="text" name="focus"
class="text-sm border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-full">
</div>
</form>
</section>
<!-- Pipeline overview --> <!-- Pipeline overview -->
<section class="bg-white rounded-lg shadow-sm p-5 mb-6"> <section class="bg-white rounded-lg shadow-sm p-5 mb-6">

View File

@ -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'
} %}
<div id="queue-body">
{% set swap_target = "#queue-body" %}
{% include "_add_to_queue_form.html" %}
<!-- In-flight source list (pending / pulled / transcribed) -->
{% if sources %}
<div class="grid gap-3">
{% for source in sources %}
<a href="/sources/{{ source.id }}"
class="block bg-white rounded-lg border border-gray-200 p-4 hover:border-indigo-400 hover:shadow-sm transition-all duration-150 group">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 group-hover:text-indigo-700 break-words">
{{ source.title }}
</h3>
<p class="text-xs text-gray-400 mt-0.5 break-all">{{ source.url }}</p>
{% if source.focus %}
<p class="text-xs text-gray-500 mt-1 italic break-words">Focus: {{ source.focus }}</p>
{% endif %}
</div>
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
<span class="text-xs px-2 py-0.5 rounded-full font-medium
{{ status_colors.get(source.status, 'bg-gray-100 text-gray-700') }}">
{{ source.status }}
</span>
<span class="text-xs px-2 py-0.5 rounded border font-medium
{{ domain_colors.get(source.domain, 'bg-gray-50 text-gray-600 border-gray-200') }}">
{{ source.domain }}
</span>
<span class="text-xs text-gray-400">{{ source.ingested_at }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-20 bg-white rounded-lg border border-dashed border-gray-300">
<p class="text-gray-400 text-lg">Queue is empty.</p>
<p class="text-gray-400 text-sm mt-1">Add a URL above to get started.</p>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Queue — second-brain{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-semibold text-gray-800">
Queue
<span class="text-base font-normal text-gray-500 ml-2">({{ sources|length }})</span>
</h2>
<span class="text-sm text-gray-500">pending → pulled → transcribed</span>
</div>
{% include "_queue_body.html" %}
{% endblock %}