Files
tapir/internal/web/handlers.go
T
mathias d9b7107ccb feat(web): sleek Stage-0 reader — design system, card list, reader detail
Implements the Top 5 fixes from the Stage-0 UX review (docs/ux-review/UX-REVIEW.md):

1. Dark mode: full light+dark custom-property palette (bg/fg/muted/line/accent/
   card) under :root + @media (prefers-color-scheme: dark), applied to body.
   color-scheme: light dark is now actually honoured — summary text was invisible
   on a dark canvas before.
2. Table -> responsive card list: one card per summary (title link, channel·date
   meta, provider chip, fallback badge, action state). Single-column reflow at
   375px, no horizontal crush.
3. Minimal design system: 4/8px spacing scale, one accent, styled accent links
   (underline-on-hover), real buttons with active/pressed state, consistent
   radius and dividers — applied across list + detail.
4. Detail page as a reader: prose capped at 38rem, title->meta->summary->
   highlights->takeaways hierarchy with section rules, 1.7 line-height. Meta is
   built from non-empty parts (detailMeta) so the no-video edge case no longer
   renders a stray "· — ·".
5. Contrast + a11y: muted bumped to #595959 (~7:1, clears WCAG AA), fallback
   badge gets vertical padding + aria-label/title, friendly first-run empty
   state, hx-indicator on the filter form.

Tests updated for the card markup (table -> cards); missing date is now omitted
rather than em-dashed. task check green; HTMX action toggles verified working.
2026-06-03 00:20:22 +02:00

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, summaryList(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"
}