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>
167 lines
5.3 KiB
Go
167 lines
5.3 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
)
|
|
|
|
// Store is the read/write surface the web handlers depend on — a narrow port over
|
|
// the Postgres store (Clean Architecture: handlers depend on this interface, not
|
|
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
|
// fake without a database.
|
|
type Store interface {
|
|
ListSummaries(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
|
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
|
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
|
SetAction(ctx context.Context, userID, videoID, action string) error
|
|
ClearAction(ctx context.Context, userID, videoID, action string) error
|
|
}
|
|
|
|
// App is the Stage-0 web surface: handlers over the store, gated by an Auth
|
|
// implementation. UserID is the single configured tapir user every store
|
|
// operation runs as (ADR-011 — Auth only gates access; it does not select the
|
|
// store identity).
|
|
type App struct {
|
|
Store Store
|
|
Auth Auth
|
|
UserID string
|
|
Log *slog.Logger
|
|
}
|
|
|
|
func (a *App) logger() *slog.Logger {
|
|
if a.Log != nil {
|
|
return a.Log
|
|
}
|
|
return slog.Default()
|
|
}
|
|
|
|
// Router wires the routes: /healthz (no auth) and /auth/* (the Auth impl's own
|
|
// endpoints) sit outside the guard; everything else is wrapped by
|
|
// Auth.Middleware. Go 1.22+ method+path patterns route directly.
|
|
func (a *App) Router() http.Handler {
|
|
root := http.NewServeMux()
|
|
root.HandleFunc("GET /healthz", a.handleHealthz)
|
|
root.Handle("GET /static/", staticHandler())
|
|
root.Handle("/auth/", a.Auth.Routes())
|
|
|
|
app := http.NewServeMux()
|
|
app.HandleFunc("GET /{$}", a.handleList)
|
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
|
|
|
root.Handle("/", a.Auth.Middleware(app))
|
|
return root
|
|
}
|
|
|
|
// handleHealthz is the unauthenticated liveness/readiness probe.
|
|
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
|
|
// handleList renders the summary list, applying the channel/date filters from the
|
|
// query string. An HTMX request gets only the table fragment so the filter form
|
|
// can swap #summary-list in place; a plain request gets the full page.
|
|
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
f := Filter{
|
|
Channel: q.Get("channel"),
|
|
From: q.Get("from"),
|
|
To: q.Get("to"),
|
|
}
|
|
|
|
rows, err := a.Store.ListSummaries(r.Context(), a.UserID, 0)
|
|
if err != nil {
|
|
a.serverError(w, r, "list summaries", err)
|
|
return
|
|
}
|
|
rows = f.apply(rows)
|
|
|
|
if isHTMX(r) {
|
|
a.render(w, r, summaryTable(rows))
|
|
return
|
|
}
|
|
a.render(w, r, ListPage(rows, f))
|
|
}
|
|
|
|
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
|
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|
videoID := r.PathValue("videoId")
|
|
row, err := a.Store.GetSummaryByVideo(r.Context(), a.UserID, videoID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
a.serverError(w, r, "get summary", err)
|
|
return
|
|
}
|
|
a.render(w, r, DetailPage(*row))
|
|
}
|
|
|
|
// handleAction toggles one action: re-clicking an active verb clears it, else it
|
|
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
|
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
|
// the detail page (POST→redirect→GET).
|
|
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|
videoID := r.PathValue("videoId")
|
|
action := r.FormValue("action")
|
|
if !isActionVerb(action) {
|
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
current, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
|
|
if err != nil {
|
|
a.serverError(w, r, "read actions", err)
|
|
return
|
|
}
|
|
|
|
if actionSet(current[videoID])[action] {
|
|
err = a.Store.ClearAction(r.Context(), a.UserID, videoID, action)
|
|
} else {
|
|
err = a.Store.SetAction(r.Context(), a.UserID, videoID, action)
|
|
}
|
|
if err != nil {
|
|
a.serverError(w, r, "toggle action", err)
|
|
return
|
|
}
|
|
|
|
updated, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID})
|
|
if err != nil {
|
|
a.serverError(w, r, "read actions", err)
|
|
return
|
|
}
|
|
|
|
if isHTMX(r) {
|
|
a.render(w, r, ActionButtons(videoID, actionSet(updated[videoID])))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
|
}
|
|
|
|
// render writes a templ component as HTML. A render error is logged, not retried:
|
|
// headers may already be flushed, so there is nothing useful to send the client.
|
|
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := c.Render(r.Context(), w); err != nil {
|
|
a.logger().Error("render", "path", r.URL.Path, "err", err)
|
|
}
|
|
}
|
|
|
|
func (a *App) serverError(w http.ResponseWriter, r *http.Request, op string, err error) {
|
|
a.logger().Error("handler error", "op", op, "path", r.URL.Path, "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
|
|
// isHTMX reports whether the request came from HTMX, which sets HX-Request: true.
|
|
func isHTMX(r *http.Request) bool {
|
|
return r.Header.Get("HX-Request") == "true"
|
|
}
|