web: add-to-queue form on the dashboard (HTMX, parity with CLI add)

Extracts the `second-brain add` CLI's queueing logic into a service
module (sources_service.add_source) so the CLI and the new web POST
route share the same validation, dedupe, and source_type heuristic —
no behaviour drift between the two entry points.

UI:
- The dashboard body (Pipeline counts + Settings snapshot + Recent
  activity) moves into _dashboard_body.html, wrapped in
  `<div id="dashboard-body">`. HTMX targets that id for swap.
- A new "Add to queue" section sits at the top of the partial: URL
  (required, type=url), Domain (select matching DOMAINS), Title and
  Focus (both optional, match the CLI flags). hx-post=/sources/add,
  hx-target=#dashboard-body, hx-swap=outerHTML — same pattern as the
  settings save form.

Route:
- POST /sources/add calls sources_service.add_source inside a single
  transaction, then re-renders _dashboard_body.html so the pipeline
  counts and recent-activity list update in place. Flash slots above
  the form report:
    ✓ Queued <type>: <url>            on a fresh add
    ✓ Already queued (id=…, status=…) on a dupe (matches CLI text)
    ✗ <validation message>            on bad URL / unknown domain

CLI:
- `second-brain add` now delegates to the same service. Field values
  for the success echo are captured inside the session block so a
  post-commit detached-instance access can't fail. Bad input exits 2
  with a clear stderr message instead of raising.

Live-verified through the web container on herbys-dev: dashboard
renders the form, happy add lands a row, dup-detect matches the CLI
phrasing, two validation errors surface as red flashes, swap target
id survives across swaps, no tracebacks in uvicorn logs.
This commit is contained in:
Travis Herbranson 2026-05-25 12:59:02 -04:00
parent 597c83649c
commit 956bf8d0c3
5 changed files with 431 additions and 213 deletions

View File

@ -11,8 +11,8 @@ Commands:
from __future__ import annotations from __future__ import annotations
import sys
from typing import Optional from typing import Optional
from urllib.parse import urlparse
import click import click
@ -50,32 +50,33 @@ def cli() -> None:
@click.option("--title", "-t", default=None, help="Override title.") @click.option("--title", "-t", default=None, help="Override title.")
def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None: def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None:
"""Queue a source URL for processing.""" """Queue a source URL for processing."""
from second_brain.sources_service import add_source
config = load_config() config = load_config()
config.ensure_dirs() config.ensure_dirs()
db = get_database() db = get_database()
source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO try:
with db.session() as sess: with db.session() as sess:
existing = sess.query(Source).filter_by(url=url).first() result = add_source(
if existing: sess, url=url, domain=domain, focus=focus, title=title
click.echo(
f"[add] Already queued (id={existing.id}, status={existing.status.value})"
) )
# Capture display fields inside the session — `result.source`
# detaches on commit and accessing it later would error.
source_id = result.source.id
source_url = result.source.url
source_type_value = result.source.source_type.value
existing_status = result.source.status.value
added = result.added
except ValueError as exc:
click.echo(f"[add] Error: {exc}", err=True)
sys.exit(2)
if not added:
click.echo(f"[add] Already queued (id={source_id}, status={existing_status})")
return return
source = Source( click.echo(f"[add] Queued {source_type_value}: {source_url}")
url=url,
title=title,
domain=domain,
focus=focus,
source_type=source_type,
status=SourceStatus.PENDING,
ingested_at=utcnow(),
)
sess.add(source)
click.echo(f"[add] Queued {source_type.value}: {url}")
click.echo(f" domain={domain}" + (f" focus={focus}" if focus else "")) click.echo(f" domain={domain}" + (f" focus={focus}" if focus else ""))
@ -348,19 +349,5 @@ def transcribe_worker(
click.echo(f"[transcribe-worker] exiting; processed={processed}") click.echo(f"[transcribe-worker] exiting; processed={processed}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_article(url: str) -> bool:
parsed = urlparse(url)
video_hosts = {
"youtube.com", "www.youtube.com", "youtu.be",
"vimeo.com", "www.vimeo.com",
}
return parsed.netloc not in video_hosts
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View File

@ -0,0 +1,112 @@
"""Service-layer helpers for the `sources` table.
Single source of truth for "queue a URL" both the `second-brain add`
CLI and the web POST /sources/add route call into here, so they can't
drift on validation, dedupe, or default semantics.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from second_brain.config import DOMAINS
from second_brain.models import Source, SourceStatus, SourceType, utcnow
_VIDEO_HOSTS = {
"youtube.com",
"www.youtube.com",
"youtu.be",
"vimeo.com",
"www.vimeo.com",
}
def is_article_url(url: str) -> bool:
"""Match the CLI's `_is_article` heuristic: anything not on a known
video host is treated as an article."""
parsed = urlparse(url)
return parsed.netloc not in _VIDEO_HOSTS
def validate_url(url: str) -> str:
"""Normalize and validate the input URL. Raises ValueError on bad shape."""
if url is None:
raise ValueError("url is required")
url = url.strip()
if not url:
raise ValueError("url is required")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("url must use http or https")
if not parsed.netloc:
raise ValueError("url is missing a host")
return url
@dataclass
class AddResult:
"""Outcome of `add_source`. `added=False` means the URL was already queued."""
source: Source
added: bool
def add_source(
sess: Session,
*,
url: str,
domain: str = "development",
focus: Optional[str] = None,
title: Optional[str] = None,
) -> AddResult:
"""Queue a single source URL.
Mirrors the CLI add path exactly:
- normalize + validate URL
- reject unknown domains
- if the URL is already queued, return the existing row with added=False
- otherwise insert a PENDING row and return it with added=True
Does NOT open or commit a session caller owns the transaction
boundary (matches the rest of the codebase). For the CLI, that's
`db.session()`; for the web route, the request handler's
`with db.session()` block.
"""
url = validate_url(url)
if domain not in DOMAINS:
raise ValueError(
f"unknown domain {domain!r}; valid: {', '.join(DOMAINS)}"
)
title = (title or "").strip() or None
focus = (focus or "").strip() or None
existing = sess.query(Source).filter_by(url=url).first()
if existing is not None:
return AddResult(source=existing, added=False)
source_type = (
SourceType.ARTICLE if is_article_url(url) else SourceType.VIDEO
)
source = Source(
url=url,
title=title,
domain=domain,
focus=focus,
source_type=source_type,
status=SourceStatus.PENDING,
ingested_at=utcnow(),
)
sess.add(source)
sess.flush()
return AddResult(source=source, added=True)
__all__ = ["AddResult", "add_source", "is_article_url", "validate_url"]

View File

@ -29,6 +29,7 @@ from second_brain.settings_store import (
parse_time_or_none, parse_time_or_none,
update_settings, update_settings,
) )
from second_brain.sources_service import add_source
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# App setup # App setup
@ -309,13 +310,17 @@ def _source_to_response(s: Source) -> SourceResponse:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/dashboard", response_class=HTMLResponse) def _build_dashboard_context(
async def dashboard(request: Request): sess,
"""Overview: status counts, recent activity, and a settings snapshot.""" *,
db = get_database() add_flash: Optional[str] = None,
with db.session() as sess: add_error: Optional[str] = None,
# Counts by status. Materialise inside the session so the keys stay ) -> dict:
# plain str → int and templates don't try to touch a closed session. """Collect everything the dashboard partial needs from one session.
Materialise inside the session so the keys stay plain str int /
detached dicts; templates never touch a closed session.
"""
rows = ( rows = (
sess.query(Source.status, func.count(Source.id)) sess.query(Source.status, func.count(Source.id))
.group_by(Source.status) .group_by(Source.status)
@ -327,27 +332,72 @@ async def dashboard(request: Request):
total = sum(counts.values()) total = sum(counts.values())
recent_rows = ( recent_rows = (
sess.query(Source) sess.query(Source).order_by(Source.ingested_at.desc()).limit(10).all()
.order_by(Source.ingested_at.desc())
.limit(10)
.all()
) )
recent = [_source_to_dict(s) for s in recent_rows] recent = [_source_to_dict(s) for s in recent_rows]
settings_row = get_settings(sess) settings_summary = _settings_to_dict(get_settings(sess))
settings_summary = _settings_to_dict(settings_row)
return templates.TemplateResponse( return {
request,
"dashboard.html",
{
"counts": counts, "counts": counts,
"total": total, "total": total,
"recent": recent, "recent": recent,
"statuses_in_order": [s.value for s in SourceStatus], "statuses_in_order": [s.value for s in SourceStatus],
"settings": settings_summary, "settings": settings_summary,
}, "domains": DOMAINS,
"add_flash": add_flash,
"add_error": add_error,
}
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Overview: status counts, recent activity, and a settings snapshot."""
db = get_database()
with db.session() as sess:
ctx = _build_dashboard_context(sess)
return templates.TemplateResponse(request, "dashboard.html", ctx)
@app.post("/sources/add", response_class=HTMLResponse)
async def sources_add(
request: Request,
url: str = Form(...),
domain: str = Form("development"),
title: Optional[str] = Form(None),
focus: Optional[str] = Form(None),
):
"""HTMX endpoint — queue a source, re-render the dashboard body."""
db = get_database()
add_flash: Optional[str] = None
add_error: Optional[str] = None
try:
with db.session() as sess:
result = add_source(
sess, url=url, domain=domain, focus=focus, title=title
) )
# Snapshot the message inside the session — `result.source`
# detaches on commit.
if result.added:
add_flash = (
f"Queued {result.source.source_type.value}: {result.source.url}"
)
else:
add_flash = (
f"Already queued (id={result.source.id}, "
f"status={result.source.status.value})"
)
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
)
return templates.TemplateResponse(request, "_dashboard_body.html", ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -0,0 +1,215 @@
{# HTMX-replaceable dashboard body. POST /sources/add re-renders this
partial; hx-target="#dashboard-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="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>
<!-- Pipeline overview -->
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
Pipeline
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
{% for status in statuses_in_order %}
<a href="/?status={{ status }}"
class="block rounded-lg border border-gray-200 hover:border-indigo-400 hover:shadow-sm transition-all px-3 py-3">
<div class="text-2xl font-bold text-gray-900 leading-none">{{ counts.get(status, 0) }}</div>
<div class="mt-1.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium
{{ status_colors.get(status, 'bg-gray-100 text-gray-700') }}">
{{ status }}
</span>
</div>
</a>
{% endfor %}
</div>
</section>
<!-- Settings snapshot + Recent activity, side by side on wide screens -->
<div class="grid lg:grid-cols-3 gap-6">
<!-- Settings snapshot -->
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-1">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Settings
</h3>
<a href="/settings" class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">
edit →
</a>
</div>
<dl class="text-sm space-y-2">
<div class="flex justify-between items-center">
<dt class="text-gray-600">Transcription</dt>
<dd>
{% if settings.transcription_enabled %}
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
{% else %}
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> active window</dt>
<dd class="text-gray-800 font-mono text-xs">
{% if settings.transcription_active_hours_start or settings.transcription_active_hours_end %}
{{ settings.transcription_active_hours_start or "—" }} {{ settings.transcription_active_hours_end or "—" }}
{% else %}
always
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> GPU jobs</dt>
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_concurrent_gpu_jobs }}</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> max items/run</dt>
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_items_per_run }}</dd>
</div>
<div class="border-t border-gray-100 pt-2 mt-2"></div>
<div class="flex justify-between items-center">
<dt class="text-gray-600">Extraction</dt>
<dd>
{% if settings.extraction_enabled %}
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
{% else %}
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> active window</dt>
<dd class="text-gray-800 font-mono text-xs">
{% if settings.extraction_active_hours_start or settings.extraction_active_hours_end %}
{{ settings.extraction_active_hours_start or "—" }} {{ settings.extraction_active_hours_end or "—" }}
{% else %}
always
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> max items/run</dt>
<dd class="text-gray-800 font-mono text-xs">{{ settings.extraction_max_items_per_run }}</dd>
</div>
</dl>
<p class="text-xs text-gray-400 mt-4">
updated {{ settings.updated_at }}
</p>
</section>
<!-- Recent activity -->
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-2">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
Recent activity
</h3>
{% if recent %}
<div class="divide-y divide-gray-100">
{% for source in recent %}
<a href="/sources/{{ source.id }}"
class="flex items-start justify-between gap-3 py-2.5 hover:bg-gray-50 -mx-2 px-2 rounded transition-colors">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ source.title }}</p>
<p class="text-xs text-gray-400 truncate">{{ source.url }}</p>
</div>
<div class="flex flex-col items-end gap-1 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>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-400 py-8 text-center">No sources yet.</p>
{% endif %}
</section>
</div>
</div>

View File

@ -3,156 +3,10 @@
{% block title %}Dashboard — second-brain{% endblock %} {% block title %}Dashboard — second-brain{% endblock %}
{% block content %} {% block content %}
{% 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 class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-semibold text-gray-800">Dashboard</h2> <h2 class="text-2xl font-semibold text-gray-800">Dashboard</h2>
<span class="text-sm text-gray-500">total sources: {{ total }}</span> <span class="text-sm text-gray-500">total sources: {{ total }}</span>
</div> </div>
<!-- Pipeline overview --> {% include "_dashboard_body.html" %}
<section class="bg-white rounded-lg shadow-sm p-5 mb-6">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
Pipeline
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
{% for status in statuses_in_order %}
<a href="/?status={{ status }}"
class="block rounded-lg border border-gray-200 hover:border-indigo-400 hover:shadow-sm transition-all px-3 py-3">
<div class="text-2xl font-bold text-gray-900 leading-none">{{ counts.get(status, 0) }}</div>
<div class="mt-1.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium
{{ status_colors.get(status, 'bg-gray-100 text-gray-700') }}">
{{ status }}
</span>
</div>
</a>
{% endfor %}
</div>
</section>
<!-- Settings snapshot + Recent activity, side by side on wide screens -->
<div class="grid lg:grid-cols-3 gap-6">
<!-- Settings snapshot -->
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-1">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500">
Settings
</h3>
<a href="/settings" class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">
edit →
</a>
</div>
<dl class="text-sm space-y-2">
<div class="flex justify-between items-center">
<dt class="text-gray-600">Transcription</dt>
<dd>
{% if settings.transcription_enabled %}
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
{% else %}
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> active window</dt>
<dd class="text-gray-800 font-mono text-xs">
{% if settings.transcription_active_hours_start or settings.transcription_active_hours_end %}
{{ settings.transcription_active_hours_start or "—" }} {{ settings.transcription_active_hours_end or "—" }}
{% else %}
always
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> GPU jobs</dt>
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_concurrent_gpu_jobs }}</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> max items/run</dt>
<dd class="text-gray-800 font-mono text-xs">{{ settings.transcription_max_items_per_run }}</dd>
</div>
<div class="border-t border-gray-100 pt-2 mt-2"></div>
<div class="flex justify-between items-center">
<dt class="text-gray-600">Extraction</dt>
<dd>
{% if settings.extraction_enabled %}
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">enabled</span>
{% else %}
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">paused</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> active window</dt>
<dd class="text-gray-800 font-mono text-xs">
{% if settings.extraction_active_hours_start or settings.extraction_active_hours_end %}
{{ settings.extraction_active_hours_start or "—" }} {{ settings.extraction_active_hours_end or "—" }}
{% else %}
always
{% endif %}
</dd>
</div>
<div class="flex justify-between items-center">
<dt class="text-gray-600"> max items/run</dt>
<dd class="text-gray-800 font-mono text-xs">{{ settings.extraction_max_items_per_run }}</dd>
</div>
</dl>
<p class="text-xs text-gray-400 mt-4">
updated {{ settings.updated_at }}
</p>
</section>
<!-- Recent activity -->
<section class="bg-white rounded-lg shadow-sm p-5 lg:col-span-2">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-3">
Recent activity
</h3>
{% if recent %}
<div class="divide-y divide-gray-100">
{% for source in recent %}
<a href="/sources/{{ source.id }}"
class="flex items-start justify-between gap-3 py-2.5 hover:bg-gray-50 -mx-2 px-2 rounded transition-colors">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ source.title }}</p>
<p class="text-xs text-gray-400 truncate">{{ source.url }}</p>
</div>
<div class="flex flex-col items-end gap-1 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>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-400 py-8 text-center">No sources yet.</p>
{% endif %}
</section>
</div>
{% endblock %} {% endblock %}