feat(web): immediate summarize + status poll + ASCII tapir spinner

Clicking "Summarize" now runs the summary in the background (when a Processor is
wired) instead of only queuing it. The handler flips the DB flag, kicks off the
work on a detached context, and returns an animated "processing" card that polls
GET /v/{id}/status every 2s via HTMX. Status returns the summary card once it
lands (no poll → polling stops), the animation while in-flight, or the Queued
card otherwise. Queue-only behaviour is unchanged when no Processor is set.

The spinner is a CSS-only cross-fade of three ASCII tapir frames — no JS, honours
prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:48:52 +02:00
co-authored by Claude Opus 4.8
parent 404f74c55c
commit 25215cbcbd
5 changed files with 622 additions and 195 deletions
+58 -4
View File
@@ -91,6 +91,7 @@ func (a *App) Router() http.Handler {
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 /v/{videoId}/status", a.handleStatus)
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
@@ -215,10 +216,12 @@ 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.
// handleRequestSummarize handles the "Summarize" button. It always flips the DB
// flag (summarize_requested) so the work is durable. With a Processor wired it
// then summarizes immediately in the background and answers with the animated
// processing card that polls /status until done; without one (queue-only) it
// answers with the "Queued" card — the next `tapir run` does the work. Without
// JS it redirects back to the list (POST→redirect→GET).
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
@@ -245,9 +248,60 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
a.serverError(w, r, "get video", err)
return
}
if a.Processor != nil {
a.startProcessing(userID, videoID)
a.render(w, r, processingCard(*row))
return
}
a.render(w, r, VideoCard(*row))
}
// startProcessing marks a video in-flight and summarizes it in the background.
// The goroutine uses a detached context — not the request's, which is cancelled
// when the handler returns — and clears the in-flight mark on completion. On
// error the DB flag stays set, so the video remains queued for the next
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
func (a *App) startProcessing(userID, videoID string) {
key := processingKey(userID, videoID)
a.Processing.Add(key)
go func() {
defer a.Processing.Remove(key)
if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
}
}()
}
// handleStatus is the HTMX poll target for an in-flight summarization. It returns
// the card in its current state: the full summary card once the summary exists,
// otherwise the animated processing card while still in-flight (which keeps
// polling), or the queued/button card when neither holds. VideoCard carries no
// polling attributes, so HTMX stops polling once it swaps in.
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
a.serverError(w, r, "get video", err)
return
}
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
a.render(w, r, VideoCard(*row))
return
}
a.render(w, r, processingCard(*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.