Files
tapir/internal/web/views.templ
T
mathiasandClaude Opus 4.8 3014ee0d60
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Has been skipped
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>
2026-06-03 19:31:29 +02:00

354 lines
11 KiB
Templ

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="/static/htmx.min.js" defer></script>
@templ.Raw(styleTag)
</head>
<body>
<header>
<a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a></nav>
</header>
<main>
{ children... }
</main>
</body>
</html>
}
// flashBanner renders a one-shot notification for a flash code (connect success/
// failure, disconnect, delete, registration). An empty or unknown code renders
// nothing, so it is safe to drop into any page unconditionally. Reused across the
// app — not per-page ad-hoc markup.
templ flashBanner(code string) {
if f, ok := flashFor(code); ok {
<div class={ "flash", "flash-" + f.Kind } role="status" aria-live="polite">{ f.Message }</div>
}
}
// 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. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect.
templ ListPage(rows []store.SummaryRow, f Filter, flash string) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
@filterForm(f)
<div id="summary-list">
@summaryList(rows)
</div>
}
}
templ filterForm(f Filter) {
<form
class="filters"
method="get"
action="/"
hx-get="/"
hx-target="#summary-list"
hx-swap="innerHTML"
hx-indicator="#filter-indicator"
>
<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" class="btn">Filter</button>
<span id="filter-indicator" class="htmx-indicator">filtering…</span>
</form>
}
// 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 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 {
@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) {
@Layout("Tapir — " + displayTitle(r)) {
<article class="detail">
<h1>{ displayTitle(r) }</h1>
<p class="meta">
if detailMeta(r) != "" {
<span>{ detailMeta(r) }</span>
}
if r.FallbackUsed {
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
}
</p>
if url, ok := embedURL(r.ProviderVideoID); ok {
<div class="embed">
<iframe
src={ url }
title={ displayTitle(r) }
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
}
if r.URL != "" {
<p class="source"><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>
}
}
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
// subject with no tapir user picks a display name and accepts the terms to create
// their account. errMsg, when set, reports a validation problem on the prior POST.
templ RegisterPage(email, errMsg string) {
@Layout("Tapir — Register") {
<article class="register">
<h1>Complete your registration</h1>
if email != "" {
<p class="meta">Signed in as { email }.</p>
}
<p>Choose a display name to finish setting up your Tapir account.</p>
if errMsg != "" {
<p class="error" role="alert">{ errMsg }</p>
}
<form method="post" action="/register" class="register-form">
<label>
Display name
<input type="text" name="display_name" required autofocus/>
</label>
<label class="checkbox">
<input type="checkbox" name="accept_terms" value="yes" required/>
I accept the terms of use
</label>
<button type="submit" class="btn">Register</button>
</form>
</article>
}
}
// AccountPage is the account-management view: the registered display name and
// 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, autoSummarize bool, flash string) {
@Layout("Tapir — Account") {
@flashBanner(flash)
<article class="account">
<h1>Account</h1>
<dl class="account-meta">
<dt>Display name</dt>
<dd>{ displayNameOr(displayName) }</dd>
if email != "" {
<dt>Signed in as</dt>
<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 {
<p class="muted">No connected video accounts yet.</p>
} else {
<ul class="conn-list">
for _, c := range conns {
<li class="conn">
<div class="conn-main">
<span class="conn-provider">{ providerLabel(c.Provider) }</span>
if c.ProviderAccount != "" {
<span class="muted">{ c.ProviderAccount }</span>
}
<span class="chip">{ c.Status }</span>
</div>
<div class="conn-meta muted">connected { c.ConnectedAt.Format("2006-01-02") }</div>
<form method="post" action={ disconnectURL(c.Provider) }>
<button type="submit" class="btn-secondary">Disconnect</button>
</form>
</li>
}
</ul>
}
if !hasYouTube(conns) {
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
}
</section>
<section class="danger-zone">
<h2>Delete account</h2>
<p class="muted">
Permanently remove your Tapir account and all of its data summaries,
watch/skip/save actions, and connected accounts. This cannot be undone.
</p>
<details class="confirm-delete">
<summary class="btn-danger">Delete account…</summary>
<div class="confirm-body">
<p>This permanently deletes your account and all data. Are you sure?</p>
<form method="post" action="/account/delete">
<button type="submit" class="btn-danger">Yes, permanently delete my account</button>
</form>
</div>
</details>
</section>
</article>
}
}
// 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
// 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>
}