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
+40
View File
@@ -176,6 +176,30 @@ func summarizeURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/summarize")
}
// statusURL builds the processing-status poll path (GET) for a video id — the
// HTMX poll target while an immediate summarization is in flight.
func statusURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/status")
}
// tapirFrame1..3 are the ASCII tapir frames cycled by the TapirSpinner CSS
// animation. Three near-identical frames (snout and feet shift) read as a stocky,
// big-snouted tapir ambling in place while a summary is generated.
const (
tapirFrame1 = ` /\_____/\
( · ω · )
) ∩∩ (
(__( )__)`
tapirFrame2 = ` /\_____/\
( · ω · )
) ∩∩ (
(__( ^ )__)`
tapirFrame3 = ` /\_____/\
( · ω · )
) ∪∪ (
(__( )__)`
)
// summarizeModeLabel names the current mode for display.
func summarizeModeLabel(auto bool) string {
if auto {
@@ -381,6 +405,22 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.card-pending { border-style: dashed; }
.card-pending .card-title { color: var(--muted); font-weight: 600; }
/* summarizing animation — a little ASCII tapir ambling in place. Three frames
are stacked absolutely and cross-faded by a stepped keyframe with staggered
delays, so exactly one shows at a time (no JS). */
.card-processing { border-style: dashed; }
.tapir-spin { position: relative; height: 5.2em; margin: var(--s2) 0; }
.tapir-spin pre { position: absolute; inset: 0; margin: 0; opacity: 0; font: .9rem/1.15 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--accent); animation: tapir-cycle 1.2s steps(1, end) infinite; }
.tapir-spin pre:nth-child(1) { animation-delay: 0s; }
.tapir-spin pre:nth-child(2) { animation-delay: .4s; }
.tapir-spin pre:nth-child(3) { animation-delay: .8s; }
@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
@media (prefers-reduced-motion: reduce) {
.tapir-spin pre { animation: none; }
.tapir-spin pre:nth-child(1) { opacity: 1; }
}
/* summarization mode toggle on the account page */
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
.summarize-mode p { margin: 0; }