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:
@@ -14,10 +14,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||
@@ -28,6 +31,7 @@ import (
|
||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -50,6 +54,8 @@ func main() {
|
||||
err = cmdAuth(ctx, log)
|
||||
case "run":
|
||||
err = cmdRun(ctx, log)
|
||||
case "serve":
|
||||
err = cmdServe(ctx, log)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@@ -67,6 +73,7 @@ func usage() {
|
||||
usage:
|
||||
tapir auth one-time: authorize YouTube and store a refresh token
|
||||
tapir run detect new videos, summarize, deliver to your store
|
||||
tapir serve run the web UI (read summaries, record watch/skip/save)
|
||||
tapir list [-limit N] list stored summaries, recent first
|
||||
tapir show <video-id> show one summary in full
|
||||
|
||||
@@ -135,3 +142,49 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
|
||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
||||
return r.Loop(ctx, cfg.PollInterval)
|
||||
}
|
||||
|
||||
// cmdServe runs the Stage-0 web UI: the summary reader over the existing store
|
||||
// (ADR-003 — a new transport, not new core). Auth is the StubAuth allow-all seam
|
||||
// keyed to the configured user; the Conductor swaps in oidc.DexAuth at merge —
|
||||
// the only line that changes is the `authn` assignment below.
|
||||
func cmdServe(ctx context.Context, log *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ValidateForServe(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := store.New(ctx, cfg.DBDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
// Auth seam: StubAuth allows every request as the configured user. The
|
||||
// Conductor replaces this with oidc.DexAuth (lane B) at merge — nothing else
|
||||
// in this function or the handlers changes (handlers depend on web.Auth only).
|
||||
var authn web.Auth = web.StubAuth{U: web.User{Subject: cfg.UserID}}
|
||||
|
||||
app := &web.App{Store: st, Auth: authn, UserID: cfg.UserID, Log: log}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: app.Router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown on signal: stop accepting, drain in-flight requests.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user