feat(web): summarization-mode UI — all-videos list, Summarize queue, mode toggle
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:
+85
-27
@@ -73,44 +73,73 @@ templ filterForm(f Filter) {
|
||||
</form>
|
||||
}
|
||||
|
||||
// summaryList is the swappable list fragment: one card per summary (title link,
|
||||
// channel · date meta, provider chip, fallback badge, action state). Cards
|
||||
// reflow to a single column on mobile; an empty list shows a friendly first-run
|
||||
// state instead of a blank table.
|
||||
// summaryList is the swappable list fragment: one card per video (summarized or
|
||||
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
|
||||
// first-run state instead of a blank table.
|
||||
templ summaryList(rows []store.SummaryRow) {
|
||||
if len(rows) == 0 {
|
||||
<div class="empty">
|
||||
<strong>No summaries yet</strong>
|
||||
<span>Summaries appear here as your subscriptions are processed — run <code>tapir run</code> to fetch and summarize new videos.</span>
|
||||
<strong>No videos yet</strong>
|
||||
<span>Videos appear here as your subscriptions are processed — run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span>
|
||||
</div>
|
||||
} else {
|
||||
<ul class="cards">
|
||||
for _, r := range rows {
|
||||
<li class="card">
|
||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
||||
if cardMeta(r) != "" {
|
||||
<div class="card-meta">{ cardMeta(r) }</div>
|
||||
}
|
||||
if p := previewText(r.Summary, 160); p != "" {
|
||||
<div class="card-preview">{ p }</div>
|
||||
}
|
||||
<div class="card-foot">
|
||||
if r.AIProvider != "" {
|
||||
<span class="chip">{ r.AIProvider }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
if len(r.Actions) > 0 {
|
||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
@VideoCard(r)
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
// VideoCard is one list card, also returned standalone by POST /v/{id}/summarize
|
||||
// (HTMX swaps it in place via outerHTML). A summarized video links to its detail
|
||||
// page and shows its provider chip / fallback badge / action state. An
|
||||
// unsummarized video gets a muted "pending" treatment and either a "Summarize"
|
||||
// button (to queue it) or a "Queued" chip when already requested.
|
||||
templ VideoCard(r store.SummaryRow) {
|
||||
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||
if r.Summarized {
|
||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
||||
} else {
|
||||
<div class="card-title">{ displayTitle(r) }</div>
|
||||
}
|
||||
if cardMeta(r) != "" {
|
||||
<div class="card-meta">{ cardMeta(r) }</div>
|
||||
}
|
||||
if r.Summarized {
|
||||
if p := previewText(r.Summary, 160); p != "" {
|
||||
<div class="card-preview">{ p }</div>
|
||||
}
|
||||
}
|
||||
<div class="card-foot">
|
||||
if r.Summarized {
|
||||
if r.AIProvider != "" {
|
||||
<span class="chip">{ r.AIProvider }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
if len(r.Actions) > 0 {
|
||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||
}
|
||||
} else if r.SummarizeRequested {
|
||||
<span class="chip">Queued</span>
|
||||
<span class="card-state muted">waiting for the next run</span>
|
||||
} else {
|
||||
<form
|
||||
method="post"
|
||||
action={ summarizeURL(r.VideoID) }
|
||||
hx-post={ string(summarizeURL(r.VideoID)) }
|
||||
hx-target={ "#video-" + r.VideoID }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<button type="submit" class="btn-secondary">Summarize</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||
// and the action button group.
|
||||
templ DetailPage(r store.SummaryRow) {
|
||||
@@ -202,7 +231,7 @@ templ RegisterPage(email, errMsg string) {
|
||||
// signed-in email, the user's connected video accounts (each with a Disconnect
|
||||
// control), a Connect-YouTube link when none is connected, and the delete-account
|
||||
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
||||
templ AccountPage(displayName, email string, conns []store.Connection, flash string) {
|
||||
templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) {
|
||||
@Layout("Tapir — Account") {
|
||||
@flashBanner(flash)
|
||||
<article class="account">
|
||||
@@ -215,6 +244,15 @@ templ AccountPage(displayName, email string, conns []store.Connection, flash str
|
||||
<dd>{ email }</dd>
|
||||
}
|
||||
</dl>
|
||||
<section>
|
||||
<h2>Summarization</h2>
|
||||
<p class="muted">
|
||||
Automatic summarizes every new video as it is discovered. Manual lets you
|
||||
pick which videos to summarize — new videos appear in your list with a
|
||||
Summarize button.
|
||||
</p>
|
||||
@summarizeModeControl(autoSummarize)
|
||||
</section>
|
||||
<section>
|
||||
<h2>Connected accounts</h2>
|
||||
if len(conns) == 0 {
|
||||
@@ -262,6 +300,26 @@ templ AccountPage(displayName, email string, conns []store.Connection, flash str
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeModeControl is the auto/manual toggle, also returned standalone by
|
||||
// POST /account/summarize-mode (HTMX swaps it via outerHTML). The hidden field
|
||||
// submits the desired NEW value, so a single submit flips the mode; without JS the
|
||||
// form posts and the handler redirects back to /account.
|
||||
templ summarizeModeControl(auto bool) {
|
||||
<div id="summarize-mode" class="summarize-mode">
|
||||
<p>Current mode: <strong>{ summarizeModeLabel(auto) }</strong></p>
|
||||
<form
|
||||
method="post"
|
||||
action="/account/summarize-mode"
|
||||
hx-post="/account/summarize-mode"
|
||||
hx-target="#summarize-mode"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="enabled" value={ boolStr(!auto) }/>
|
||||
<button type="submit" class="btn-secondary">{ summarizeModeToggleLabel(auto) }</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user