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:
parent
a475893403
commit
2b40157a0d
@ -141,32 +141,41 @@ async def source_detail(request: Request, source_id: int):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/queue", response_class=HTMLResponse)
|
||||
async def queue_view(request: Request):
|
||||
"""Queue view: pending and in-flight sources."""
|
||||
db = get_database()
|
||||
in_flight_statuses = [SourceStatus.PENDING, SourceStatus.PULLED, SourceStatus.TRANSCRIBED]
|
||||
with db.session() as sess:
|
||||
_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))
|
||||
.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,
|
||||
return {
|
||||
"sources": [_source_to_dict(s) for s in sources],
|
||||
"domains": DOMAINS,
|
||||
"statuses": [s.value for s in SourceStatus],
|
||||
"selected_domain": "",
|
||||
"selected_status": "",
|
||||
"page_title": "Queue",
|
||||
},
|
||||
)
|
||||
"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, 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:
|
||||
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:
|
||||
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, "_dashboard_body.html", ctx)
|
||||
return templates.TemplateResponse(request, template_name, ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
67
src/second_brain/web/templates/_add_to_queue_form.html
Normal file
67
src/second_brain/web/templates/_add_to_queue_form.html
Normal 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>
|
||||
@ -18,68 +18,8 @@
|
||||
} %}
|
||||
<div id="dashboard-body">
|
||||
|
||||
<!-- Add source -->
|
||||
<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</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>
|
||||
{% set swap_target = "#dashboard-body" %}
|
||||
{% include "_add_to_queue_form.html" %}
|
||||
|
||||
<!-- Pipeline overview -->
|
||||
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
|
||||
|
||||
62
src/second_brain/web/templates/_queue_body.html
Normal file
62
src/second_brain/web/templates/_queue_body.html
Normal 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>
|
||||
15
src/second_brain/web/templates/queue.html
Normal file
15
src/second_brain/web/templates/queue.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user