feat(web): summarization-mode UI — all-videos list, Summarize queue, mode toggle
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped

The list now shows ALL videos (ListVideos), not just summaries. Summarized cards
are unchanged; discovered-but-unsummarized videos render with a muted "pending"
treatment and either a "Summarize" button or a "Queued" chip.

- POST /v/{videoId}/summarize queues a video (RequestSummarize) and returns the
  refreshed card — it does NOT run the engine inline; `tapir run` is the single
  summarization driver, which picks up the flag on its next pass.
- Account page gains an Automatic/Manual toggle (POST /account/summarize-mode →
  SetAutoSummarize), shown as the current mode with a one-click switch.
- Both new POSTs degrade without JS (redirect back); HTMX swaps the fragment.

VideoCard and summarizeModeControl are extracted templ fragments reused as the
HTMX swap targets. views_templ.go regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:31:29 +02:00
co-authored by Claude Opus 4.8
parent a269d4a200
commit 3014ee0d60
6 changed files with 819 additions and 327 deletions
+65 -3
View File
@@ -16,12 +16,19 @@ import (
// 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)
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
GetVideoRow(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
// Summarization mode: the per-user auto/manual toggle and the per-video
// manual queue (the "Summarize" button). The runner consumes the queue.
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
RequestSummarize(ctx context.Context, userID, videoID string) error
// Account management (the /account page, disconnect, delete-account).
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
DeleteConnection(ctx context.Context, userID, provider string) error
@@ -76,6 +83,7 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /{$}", a.handleList)
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
@@ -84,6 +92,7 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /account", a.handleAccount)
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
// CurrentUserID is set and the connection binds to the authenticated user.
@@ -121,9 +130,9 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
To: q.Get("to"),
}
rows, err := a.Store.ListSummaries(r.Context(), userID, 0)
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
if err != nil {
a.serverError(w, r, "list summaries", err)
a.serverError(w, r, "list videos", err)
return
}
rows = f.apply(rows)
@@ -199,6 +208,59 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
}
// handleRequestSummarize queues a video for manual summarization. It does NOT run
// the engine inline — it only flips summarize_requested; the next `tapir run`
// picks it up (the single summarization driver). For HTMX it returns the refreshed
// card (now showing "Queued"); without JS it redirects back to the list.
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
err := a.Store.RequestSummarize(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
a.serverError(w, r, "request summarize", err)
return
}
if !isHTMX(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
if err != nil {
a.serverError(w, r, "get video", err)
return
}
a.render(w, r, VideoCard(*row))
}
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
// submits the desired new value (enabled=true|false). For HTMX it returns the
// refreshed mode control; without JS it redirects back to the account page.
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
enabled := r.FormValue("enabled") == "true"
if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
a.serverError(w, r, "set summarize mode", err)
return
}
if !isHTMX(r) {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
a.render(w, r, summarizeModeControl(enabled))
}
// currentUserID returns the tapir user_id the registration gate resolved for this
// request. Behind the gate it is always present; a miss means a handler was
// reached without scoping (a wiring bug), so it answers 500 and reports false.