feat(web): Stage-0 reader UI — Templ+HTMX pages + tapir serve

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>
This commit is contained in:
2026-06-02 23:47:47 +02:00
co-authored by Claude Opus 4.8
parent e38fa792ee
commit 71689ced60
10 changed files with 1588 additions and 2 deletions
+16
View File
@@ -53,6 +53,9 @@ type Config struct {
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
PollInterval time.Duration
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string
}
// Defaults (see docs/homelab-integration.md). All overridable via env.
@@ -62,6 +65,7 @@ const (
defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
)
// Load reads the environment into a Config, applying defaults. It does not
@@ -80,6 +84,7 @@ func Load() (Config, error) {
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
}
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
@@ -122,6 +127,17 @@ func (c Config) ValidateForRun() error {
})
}
// ValidateForServe checks the fields the `serve` command needs: a user to act
// as, the store DSN, and a listen address. Auth (Dex) config is validated by the
// auth layer the Conductor wires in, not here.
func (c Config) ValidateForServe() error {
return c.require(map[string]string{
"TAPIR_USER_ID": c.UserID,
"TAPIR_DB_DSN": c.DBDSN,
"TAPIR_HTTP_ADDR": c.HTTPAddr,
})
}
func (c Config) require(fields map[string]string) error {
var missing []string
for name, val := range fields {