Add the lane-C reader surface: list, detail, and an action button-group
fragment over the lane-A store reads/actions, behind the web.Auth seam.
- Templ components (base layout, list+filters, detail, ActionButtons) with
committed *_templ.go so go build/task check work without the templ binary;
`task generate` regenerates. Filters and action toggles are HTMX-swapped and
degrade to plain form GET/POST (POST→303→GET) without JS.
- Handlers (internal/web): GET / (channel+date filters, in-memory),
GET /v/{videoId}, POST /v/{videoId}/action (re-click clears, else SetAction;
store enforces watched↔skipped exclusion), GET /healthz (no auth). Store ops
run as the configured UserID; Auth only gates.
- `tapir serve` wires store + StubAuth{Subject: cfg.UserID} + http.Server on
TAPIR_HTTP_ADDR (default :8080), graceful shutdown on signal. Handlers depend
only on web.Auth — Conductor swaps StubAuth → oidc.DexAuth at merge (one line
in cmdServe).
- Handler tests: real store (embedded-postgres) + StubAuth — list rows+state,
HTMX fragment vs full page, channel filter, detail highlights/takeaways,
404, action toggle+clear, no-JS redirect, bad-verb 400.
New dep: github.com/a-h/templ — the house default for typed server-rendered
HTML (CLAUDE.md stack, ui-spec.md §3). Generated code is committed so the
templ binary is build-time-optional.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
4.3 KiB
Templ
178 lines
4.3 KiB
Templ
package web
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
)
|
|
|
|
// Layout is the shared HTML shell. HTMX drives the progressive interactions
|
|
// (filters, action toggles); every interaction also degrades to a plain form
|
|
// POST/GET when JS is absent (ui-spec.md §4).
|
|
templ Layout(title string) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>{ title }</title>
|
|
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
|
@templ.Raw(styleTag)
|
|
</head>
|
|
<body>
|
|
<header><a href="/" class="brand">Tapir</a></header>
|
|
<main>
|
|
{ children... }
|
|
</main>
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// ListPage is the full summary list with the filter form. HTMX swaps only the
|
|
// #summary-list region; a non-HTMX request renders the whole page.
|
|
templ ListPage(rows []store.SummaryRow, f Filter) {
|
|
@Layout("Tapir — Summaries") {
|
|
@filterForm(f)
|
|
<div id="summary-list">
|
|
@summaryTable(rows)
|
|
</div>
|
|
}
|
|
}
|
|
|
|
templ filterForm(f Filter) {
|
|
<form
|
|
class="filters"
|
|
method="get"
|
|
action="/"
|
|
hx-get="/"
|
|
hx-target="#summary-list"
|
|
hx-swap="innerHTML"
|
|
>
|
|
<label>Channel <input type="text" name="channel" value={ f.Channel } placeholder="any"/></label>
|
|
<label>From <input type="date" name="from" value={ f.From }/></label>
|
|
<label>To <input type="date" name="to" value={ f.To }/></label>
|
|
<button type="submit">Filter</button>
|
|
</form>
|
|
}
|
|
|
|
// summaryTable is the swappable list fragment: title · channel · published ·
|
|
// provider · fallback badge · current action state.
|
|
templ summaryTable(rows []store.SummaryRow) {
|
|
<table class="summaries">
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Channel</th>
|
|
<th>Published</th>
|
|
<th>Provider</th>
|
|
<th></th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
if len(rows) == 0 {
|
|
<tr><td colspan="6" class="empty">No summaries.</td></tr>
|
|
}
|
|
for _, r := range rows {
|
|
<tr>
|
|
<td><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></td>
|
|
<td>{ r.Channel }</td>
|
|
<td>{ displayDate(r.PublishedAt) }</td>
|
|
<td>{ r.AIProvider }</td>
|
|
<td>
|
|
if r.FallbackUsed {
|
|
<span class="badge">fallback</span>
|
|
}
|
|
</td>
|
|
<td class="state">
|
|
if len(r.Actions) == 0 {
|
|
<span class="muted">—</span>
|
|
} else {
|
|
{ strings.Join(r.Actions, ", ") }
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
|
// and the action button group.
|
|
templ DetailPage(r store.SummaryRow) {
|
|
@Layout("Tapir — " + displayTitle(r)) {
|
|
<article class="detail">
|
|
<h1>{ displayTitle(r) }</h1>
|
|
<p class="meta">
|
|
{ r.Channel } · { displayDate(r.PublishedAt) } · { r.AIProvider }
|
|
if r.AIModel != "" {
|
|
{ "(" + r.AIModel + ")" }
|
|
}
|
|
if r.FallbackUsed {
|
|
<span class="badge">fallback</span>
|
|
}
|
|
</p>
|
|
if r.URL != "" {
|
|
<p><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
|
}
|
|
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
|
<section>
|
|
<h2>Summary</h2>
|
|
<p class="body">{ r.Summary }</p>
|
|
</section>
|
|
if len(r.Highlights) > 0 {
|
|
<section>
|
|
<h2>Highlights</h2>
|
|
<ul>
|
|
for _, h := range r.Highlights {
|
|
<li>{ h }</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
if len(r.Takeaways) > 0 {
|
|
<section>
|
|
<h2>Takeaways</h2>
|
|
<ul>
|
|
for _, t := range r.Takeaways {
|
|
<li>{ t }</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
|
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
|
// and without JS the form POSTs and the handler redirects back to the detail
|
|
// page. active marks the verbs currently set for (user, video).
|
|
templ ActionButtons(videoID string, active map[string]bool) {
|
|
<form
|
|
id="action-buttons"
|
|
class="actions"
|
|
method="post"
|
|
action={ actionURL(videoID) }
|
|
hx-post={ string(actionURL(videoID)) }
|
|
hx-target="#action-buttons"
|
|
hx-swap="outerHTML"
|
|
>
|
|
for _, v := range actionVerbs {
|
|
<button
|
|
type="submit"
|
|
name="action"
|
|
value={ v }
|
|
class={ "action", templ.KV("active", active[v]) }
|
|
aria-pressed={ ariaPressed(active[v]) }
|
|
>
|
|
if active[v] {
|
|
{ "✓ " + actionLabel(v) }
|
|
} else {
|
|
{ actionLabel(v) }
|
|
}
|
|
</button>
|
|
}
|
|
</form>
|
|
}
|