diff --git a/Taskfile.yml b/Taskfile.yml index db72fa4..c96e033 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -41,6 +41,11 @@ tasks: cmds: - go test ./... + generate: + desc: Regenerate Templ components (*_templ.go) from *.templ sources. + cmds: + - 'command -v templ >/dev/null 2>&1 && templ generate || go run github.com/a-h/templ/cmd/templ@latest generate' + build: desc: Compile the binary. cmds: diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 3becd30..195d76f 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -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 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 +} diff --git a/go.mod b/go.mod index f798053..40aa84e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitea.d-ma.be/mathias/tapir go 1.25.0 require ( + github.com/a-h/templ v0.3.1020 github.com/fergusstrange/embedded-postgres v1.34.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 diff --git a/go.sum b/go.sum index c950b79..59bb9fb 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -31,6 +33,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -87,8 +91,8 @@ golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 1d58cd6..03a3349 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..dd22d1b --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,165 @@ +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("/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" +} diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go new file mode 100644 index 0000000..177be64 --- /dev/null +++ b/internal/web/handlers_test.go @@ -0,0 +1,277 @@ +package web_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" + "gitea.d-ma.be/mathias/tapir/internal/domain" + "gitea.d-ma.be/mathias/tapir/internal/web" +) + +// dsn points at the in-process Postgres started in TestMain. Handler tests run +// against the real store (real SQL, the lane-A actions) behind StubAuth — the +// full read/write path minus live Dex. +var dsn string + +func TestMain(m *testing.M) { + const port = 54330 // distinct from the store package's embedded PG (54329) + dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) + + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port)) + if err := pg.Start(); err != nil { + fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) + os.Exit(1) + } + code := m.Run() + if err := pg.Stop(); err != nil { + fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err) + } + os.Exit(code) +} + +const ( + userID = "11111111-1111-1111-1111-111111111111" + videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +) + +func newStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(s.Close) + return s +} + +func rawPool(t *testing.T) *pgxpool.Pool { + t.Helper() + p, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(p.Close) + return p +} + +func resetDB(t *testing.T, p *pgxpool.Pool) { + t.Helper() + _, err := p.Exec(context.Background(), + `TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`) + require.NoError(t, err) +} + +// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to +// the configured user. This is exactly cmd/tapir's serve wiring minus Dex. +func newApp(t *testing.T) *web.App { + t.Helper() + return &web.App{ + Store: newStore(t), + Auth: web.StubAuth{U: web.User{Subject: userID}}, + UserID: userID, + } +} + +func summary(videoID, text string) domain.Summary { + return domain.Summary{ + UserID: userID, + VideoID: videoID, + Summary: text, + Highlights: []string{"highlight one", "highlight two"}, + Takeaways: []string{"takeaway one"}, + AIProvider: "local", + AIModel: "phi4-mini", + } +} + +func seedVideo(t *testing.T, p *pgxpool.Pool, videoID, title, url string, published time.Time) { + t.Helper() + var pub any + if !published.IsZero() { + pub = published + } + _, err := p.Exec(context.Background(), + `INSERT INTO videos (id, user_id, provider, provider_video_id, title, url, published_at) + VALUES ($1, $2, 'youtube', $3, $4, $5, $6)`, + videoID, userID, "pv-"+videoID[:8], title, url, pub) + require.NoError(t, err) +} + +func do(t *testing.T, app *web.App, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + app.Router().ServeHTTP(rec, req) + return rec +} + +func body(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + b, err := io.ReadAll(rec.Body) + require.NoError(t, err) + return string(b) +} + +func TestHealthzNoAuth(t *testing.T) { + app := newApp(t) + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "ok", body(t, rec)) +} + +func TestListRendersRowsAndActionState(t *testing.T) { + ctx := context.Background() + app := newApp(t) + p := rawPool(t) + resetDB(t, p) + + require.NoError(t, app.Store.SetAction(ctx, userID, videoX, "watched")) // action exists pre-summary + require.NoError(t, deliver(ctx, app, videoX, "body x")) + require.NoError(t, deliver(ctx, app, videoY, "body y")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + seedVideo(t, p, videoY, "Y Title", "https://y", time.Time{}) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusOK, rec.Code) + html := body(t, rec) + + require.Contains(t, html, " element, injected verbatim via +// templ.Raw (templ treats the body of a literal " + +const stylesheet = ` +:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#777; --line:#ddd; --accent:#2b6cb0; } +* { box-sizing: border-box; } +body { font: 15px/1.5 system-ui, sans-serif; margin: 0; color: var(--fg); } +header { padding: .75rem 1.25rem; border-bottom: 1px solid var(--line); } +.brand { font-weight: 700; text-decoration: none; color: var(--accent); } +main { max-width: 60rem; margin: 0 auto; padding: 1.25rem; } +table.summaries { width: 100%; border-collapse: collapse; } +table.summaries th, table.summaries td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +table.summaries th { font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); } +.empty { color: var(--muted); font-style: italic; } +.muted { color: var(--muted); } +.badge { display: inline-block; padding: 0 .4rem; border-radius: .4rem; background: #f0ad4e; color: #000; font-size: .75rem; } +.filters { display: flex; gap: 1rem; align-items: end; flex-wrap: wrap; margin-bottom: 1rem; } +.filters label { display: flex; flex-direction: column; font-size: .8rem; color: var(--muted); gap: .2rem; } +.filters input { font: inherit; padding: .25rem; } +.detail .meta { color: var(--muted); } +.actions { display: flex; gap: .5rem; margin: 1rem 0; } +.actions .action { font: inherit; padding: .4rem .8rem; border: 1px solid var(--line); border-radius: .4rem; background: transparent; cursor: pointer; } +.actions .action.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.body { white-space: pre-wrap; } +` diff --git a/internal/web/views.templ b/internal/web/views.templ new file mode 100644 index 0000000..8298ea8 --- /dev/null +++ b/internal/web/views.templ @@ -0,0 +1,177 @@ +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) { + + + + + + { title } + + @templ.Raw(styleTag) + + +
Tapir
+
+ { children... } +
+ + +} + +// 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) +
+ @summaryTable(rows) +
+ } +} + +templ filterForm(f Filter) { +
+ + + + +
+} + +// summaryTable is the swappable list fragment: title · channel · published · +// provider · fallback badge · current action state. +templ summaryTable(rows []store.SummaryRow) { + + + + + + + + + + + + + if len(rows) == 0 { + + } + for _, r := range rows { + + + + + + + + + } + +
TitleChannelPublishedProviderActions
No summaries.
{ displayTitle(r) }{ r.Channel }{ displayDate(r.PublishedAt) }{ r.AIProvider } + if r.FallbackUsed { + fallback + } + + if len(r.Actions) == 0 { + + } else { + { strings.Join(r.Actions, ", ") } + } +
+} + +// DetailPage is the full summary view: text, highlights, takeaways, metadata, +// and the action button group. +templ DetailPage(r store.SummaryRow) { + @Layout("Tapir — " + displayTitle(r)) { +
+

{ displayTitle(r) }

+

+ { r.Channel } · { displayDate(r.PublishedAt) } · { r.AIProvider } + if r.AIModel != "" { + { "(" + r.AIModel + ")" } + } + if r.FallbackUsed { + fallback + } +

+ if r.URL != "" { +

watch on source ↗

+ } + @ActionButtons(r.VideoID, actionSet(r.Actions)) +
+

Summary

+

{ r.Summary }

+
+ if len(r.Highlights) > 0 { +
+

Highlights

+
    + for _, h := range r.Highlights { +
  • { h }
  • + } +
+
+ } + if len(r.Takeaways) > 0 { +
+

Takeaways

+
    + for _, t := range r.Takeaways { +
  • { t }
  • + } +
+
+ } +
+ } +} + +// 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) { +
+ for _, v := range actionVerbs { + + } +
+} diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go new file mode 100644 index 0000000..203bf7c --- /dev/null +++ b/internal/web/views_templ.go @@ -0,0 +1,715 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package web + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +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). +func Layout(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 18, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(styleTag).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Tapir
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. +func ListPage(rows []store.SummaryRow, f Filter) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = filterForm(f).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = summaryTable(rows).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — Summaries").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func filterForm(f Filter) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// summaryTable is the swappable list fragment: title · channel · published · +// provider · fallback badge · current action state. +func summaryTable(rows []store.SummaryRow) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(rows) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, r := range rows { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
TitleChannelPublishedProviderActions
No summaries.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 78, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Channel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 79, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(displayDate(r.PublishedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 80, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.AIProvider) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 81, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.FallbackUsed { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "fallback") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(r.Actions) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(r.Actions, ", ")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 91, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// DetailPage is the full summary view: text, highlights, takeaways, metadata, +// and the action button group. +func DetailPage(r store.SummaryRow) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 105, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.Channel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 107, Col: 15} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(displayDate(r.PublishedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 107, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.AIProvider) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 107, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.AIModel != "" { + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs("(" + r.AIModel + ")") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 109, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if r.FallbackUsed { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "fallback") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.URL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

watch on source ↗

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = ActionButtons(r.VideoID, actionSet(r.Actions)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

Summary

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 121, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(r.Highlights) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Highlights

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, h := range r.Highlights { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(h) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 128, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(r.Takeaways) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

Takeaways

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range r.Takeaways { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(t) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 138, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — "+displayTitle(r)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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). +func ActionButtons(videoID string, active map[string]bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, v := range actionVerbs { + var templ_7745c5c3_Var30 = []any{"action", templ.KV("active", active[v])} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate