Files
tapir/internal/web/views.templ
T
mathiasandClaude Opus 4.8 fa9f101abe feat(deploy): add Dockerfile and vendor htmx for deployable image
Stage-0 web UI needs a self-contained container image. Two changes:

- Dockerfile: multi-stage build (golang:1.25 builder, CGO off + static
  link, -trimpath -s -w) into distroless static nonroot. The committed
  templ output and vendored asset mean a plain `go build` suffices — no
  codegen or CDN at build/run time. Existing .gitea CI already builds and
  pushes localhost:5000/tapir:<sha> + mirrors to GitHub (deploy patch
  intentionally omitted — cutover is held), so it only needed this file.

- Vendor htmx 1.9.12 locally (internal/web/static/, embed.FS, served at
  /static/ outside the auth guard) and point Layout at /static/htmx.min.js
  instead of unpkg. The deployed UI must not depend on an external CDN
  being reachable from the cluster.

task check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:08:35 +02:00

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="/static/htmx.min.js" 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>
}