merge: Templ+HTMX reader pages + tapir serve (Worker C, agent/ui-pages)
CI / Lint / Test / Vet (push) Failing after 7s
CI / Build & Import (push) Has been skipped
CI / Mirror to GitHub (push) Has been skipped

# Conflicts:
#	go.mod
This commit is contained in:
2026-06-02 23:57:23 +02:00
10 changed files with 1588 additions and 2 deletions
+5
View File
@@ -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:
+53
View File
@@ -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
}
+1
View File
@@ -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/coreos/go-oidc/v3 v3.18.0
github.com/fergusstrange/embedded-postgres v1.34.0
github.com/go-jose/go-jose/v4 v4.1.4
+6 -2
View File
@@ -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=
@@ -35,6 +37,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=
@@ -91,8 +95,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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=
+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 {
+165
View File
@@ -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"
}
+277
View File
@@ -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, "<html", "full page on a non-HTMX request")
require.Contains(t, html, "X Title")
require.Contains(t, html, "Y Title")
require.Contains(t, html, "youtube")
require.Contains(t, html, "watched", "current action state shown in the list")
require.Contains(t, html, "2026-01-01")
require.Contains(t, html, "—", "missing published date renders an em dash")
}
func TestListHTMXReturnsFragment(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
require.NoError(t, deliver(ctx, app, videoX, "body x"))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("HX-Request", "true")
rec := do(t, app, req)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "<table")
require.NotContains(t, html, "<html", "HTMX request gets only the table fragment")
}
func TestListChannelFilter(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "body x"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
// Channel is "youtube" for seeded rows; a non-matching filter hides them.
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=vimeo", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.NotContains(t, body(t, rec), "X Title")
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=youtube", nil))
require.Contains(t, body(t, rec), "X Title")
}
func TestDetailRendersHighlightsAndTakeaways(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "the full summary body"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "the full summary body")
require.Contains(t, html, "highlight one")
require.Contains(t, html, "takeaway one")
require.Contains(t, html, `id="action-buttons"`, "action button group present")
require.Contains(t, html, "Watched")
}
func TestDetailNotFound(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))
require.Equal(t, http.StatusNotFound, rec.Code)
}
func TestActionToggleReturnsFragment(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
require.NoError(t, deliver(ctx, app, videoX, "body x"))
// First click: sets "watched", returns the fragment with it active.
rec := postAction(t, app, videoX, "watched", true)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, `id="action-buttons"`)
require.Contains(t, html, "✓ Watched", "active verb marked")
got, err := app.Store.ActionsFor(ctx, userID, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"watched"}}, got)
// Second click on the same verb: clears it.
rec = postAction(t, app, videoX, "watched", true)
require.Equal(t, http.StatusOK, rec.Code)
require.NotContains(t, body(t, rec), "✓ Watched", "re-click cleared the action")
got, err = app.Store.ActionsFor(ctx, userID, []string{videoX})
require.NoError(t, err)
require.Empty(t, got, "action cleared in the store")
}
func TestActionNonHTMXRedirects(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
require.NoError(t, deliver(ctx, app, videoX, "body x"))
rec := postAction(t, app, videoX, "saved", false)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/v/"+videoX, rec.Header().Get("Location"))
got, err := app.Store.ActionsFor(ctx, userID, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"saved"}}, got, "action persisted on the no-JS path")
}
func TestActionRejectsUnknownVerb(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := postAction(t, app, videoX, "bookmarked", true)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
// deliver stores a summary through the App's store under test.
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
}
func postAction(t *testing.T, app *web.App, videoID, action string, htmx bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/action",
strings.NewReader("action="+action))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if htmx {
req.Header.Set("HX-Request", "true")
}
return do(t, app, req)
}
+173
View File
@@ -0,0 +1,173 @@
package web
import (
"time"
"github.com/a-h/templ"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// actionVerbs is the fixed, ordered set of action toggles rendered in the button
// group. It mirrors the store's allowed actions (store/actions.go); order here is
// the display order, not the store's.
var actionVerbs = []string{"watched", "skipped", "saved"}
// actionLabels maps each verb to its button caption.
var actionLabels = map[string]string{
"watched": "Watched",
"skipped": "Skipped",
"saved": "Saved",
}
func actionLabel(v string) string {
if l, ok := actionLabels[v]; ok {
return l
}
return v
}
// isActionVerb reports whether v is a known action verb. The handler rejects
// anything else before touching the store (defence in depth — the store also
// validates).
func isActionVerb(v string) bool {
for _, a := range actionVerbs {
if a == v {
return true
}
}
return false
}
// actionSet turns the store's active-action slice into a set for the template's
// O(1) "is this verb active?" lookups.
func actionSet(xs []string) map[string]bool {
m := make(map[string]bool, len(xs))
for _, x := range xs {
m[x] = true
}
return m
}
func ariaPressed(b bool) string {
if b {
return "true"
}
return "false"
}
// displayTitle falls back to the video id when no videos row supplied a title
// (the LEFT JOIN can yield an empty Title — see store.SummaryRow).
func displayTitle(r store.SummaryRow) string {
if r.Title != "" {
return r.Title
}
return r.VideoID
}
// displayDate renders a published date, or an em dash when absent (zero time).
func displayDate(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format("2006-01-02")
}
// videoURL builds the internal detail-page path for a video id.
func videoURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID)
}
// actionURL builds the action POST path for a video id.
func actionURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/action")
}
// externalURL passes a stored source URL through templ's URL sanitiser.
func externalURL(u string) templ.SafeURL {
return templ.URL(u)
}
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
// input verbatim; parsing happens in matchFilter.
type Filter struct {
Channel string
From string
To string
}
// matches reports whether a row satisfies the filter. Channel is an exact match;
// From/To bound PublishedAt inclusively. Unparseable or empty bounds are ignored
// (no constraint) — Stage-0 filtering is in-memory over the listed rows, not a
// store query.
func (f Filter) matches(r store.SummaryRow) bool {
if f.Channel != "" && r.Channel != f.Channel {
return false
}
if from, ok := parseDate(f.From); ok {
if r.PublishedAt.IsZero() || r.PublishedAt.Before(from) {
return false
}
}
if to, ok := parseDate(f.To); ok {
// inclusive of the whole "to" day
if r.PublishedAt.IsZero() || r.PublishedAt.After(to.Add(24*time.Hour-time.Nanosecond)) {
return false
}
}
return true
}
func parseDate(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// apply returns the subset of rows matching the filter, preserving order.
func (f Filter) apply(rows []store.SummaryRow) []store.SummaryRow {
if f == (Filter{}) {
return rows
}
out := rows[:0:0]
for _, r := range rows {
if f.matches(r) {
out = append(out, r)
}
}
return out
}
// styleTag wraps the stylesheet in a <style> element, injected verbatim via
// templ.Raw (templ treats the body of a literal <style> element as opaque text,
// not as template expressions — so the CSS is rendered as a raw node instead).
var styleTag = "<style>" + stylesheet + "</style>"
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; }
`
+177
View File
@@ -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) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
@templ.Raw(styleTag)
</head>
<body>
<header><a href="/" class="brand">Tapir</a></header>
<main>
{ children... }
</main>
</body>
</html>
}
// 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)
<div id="summary-list">
@summaryTable(rows)
</div>
}
}
templ filterForm(f Filter) {
<form
class="filters"
method="get"
action="/"
hx-get="/"
hx-target="#summary-list"
hx-swap="innerHTML"
>
<label>Channel <input type="text" name="channel" value={ f.Channel } placeholder="any"/></label>
<label>From <input type="date" name="from" value={ f.From }/></label>
<label>To <input type="date" name="to" value={ f.To }/></label>
<button type="submit">Filter</button>
</form>
}
// summaryTable is the swappable list fragment: title · channel · published ·
// provider · fallback badge · current action state.
templ summaryTable(rows []store.SummaryRow) {
<table class="summaries">
<thead>
<tr>
<th>Title</th>
<th>Channel</th>
<th>Published</th>
<th>Provider</th>
<th></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
if len(rows) == 0 {
<tr><td colspan="6" class="empty">No summaries.</td></tr>
}
for _, r := range rows {
<tr>
<td><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></td>
<td>{ r.Channel }</td>
<td>{ displayDate(r.PublishedAt) }</td>
<td>{ r.AIProvider }</td>
<td>
if r.FallbackUsed {
<span class="badge">fallback</span>
}
</td>
<td class="state">
if len(r.Actions) == 0 {
<span class="muted"></span>
} else {
{ strings.Join(r.Actions, ", ") }
}
</td>
</tr>
}
</tbody>
</table>
}
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
// and the action button group.
templ DetailPage(r store.SummaryRow) {
@Layout("Tapir — " + displayTitle(r)) {
<article class="detail">
<h1>{ displayTitle(r) }</h1>
<p class="meta">
{ r.Channel } · { displayDate(r.PublishedAt) } · { r.AIProvider }
if r.AIModel != "" {
{ "(" + r.AIModel + ")" }
}
if r.FallbackUsed {
<span class="badge">fallback</span>
}
</p>
if r.URL != "" {
<p><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p>
}
@ActionButtons(r.VideoID, actionSet(r.Actions))
<section>
<h2>Summary</h2>
<p class="body">{ r.Summary }</p>
</section>
if len(r.Highlights) > 0 {
<section>
<h2>Highlights</h2>
<ul>
for _, h := range r.Highlights {
<li>{ h }</li>
}
</ul>
</section>
}
if len(r.Takeaways) > 0 {
<section>
<h2>Takeaways</h2>
<ul>
for _, t := range r.Takeaways {
<li>{ t }</li>
}
</ul>
</section>
}
</article>
}
}
// 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) {
<form
id="action-buttons"
class="actions"
method="post"
action={ actionURL(videoID) }
hx-post={ string(actionURL(videoID)) }
hx-target="#action-buttons"
hx-swap="outerHTML"
>
for _, v := range actionVerbs {
<button
type="submit"
name="action"
value={ v }
class={ "action", templ.KV("active", active[v]) }
aria-pressed={ ariaPressed(active[v]) }
>
if active[v] {
{ "✓ " + actionLabel(v) }
} else {
{ actionLabel(v) }
}
</button>
}
</form>
}
+715
View File
@@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
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, "</title><script src=\"https://unpkg.com/htmx.org@1.9.12\" defer></script>")
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, "</head><body><header><a href=\"/\" class=\"brand\">Tapir</a></header><main>")
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, "</main></body></html>")
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, " <div id=\"summary-list\">")
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, "</div>")
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, "<form class=\"filters\" method=\"get\" action=\"/\" hx-get=\"/\" hx-target=\"#summary-list\" hx-swap=\"innerHTML\"><label>Channel <input type=\"text\" name=\"channel\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Channel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 51, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"any\"></label> <label>From <input type=\"date\" name=\"from\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.From)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 52, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></label> <label>To <input type=\"date\" name=\"to\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.To)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 53, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></label> <button type=\"submit\">Filter</button></form>")
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, "<table class=\"summaries\"><thead><tr><th>Title</th><th>Channel</th><th>Published</th><th>Provider</th><th></th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(rows) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr><td colspan=\"6\" class=\"empty\">No summaries.</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, r := range rows {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr><td><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(videoURL(r.VideoID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 78, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
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, "</a></td><td>")
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, "</td><td>")
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, "</td><td>")
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, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.FallbackUsed {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge\">fallback</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td class=\"state\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(r.Actions) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"muted\">—</span>")
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, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</tbody></table>")
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, "<article class=\"detail\"><h1>")
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, "</h1><p class=\"meta\">")
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, "<span class=\"badge\">fallback</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.URL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<p><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(externalURL(r.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 116, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" rel=\"noopener noreferrer\">watch on source ↗</a></p>")
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, "<section><h2>Summary</h2><p class=\"body\">")
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, "</p></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(r.Highlights) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section><h2>Highlights</h2><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, h := range r.Highlights {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<li>")
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, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</ul></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(r.Takeaways) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section><h2>Takeaways</h2><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, t := range r.Takeaways {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li>")
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, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</ul></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</article>")
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, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 templ.SafeURL
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 156, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 157, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
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, "<button type=\"submit\" name=\"action\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 165, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" aria-pressed=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 167, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if active[v] {
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 170, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 172, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate