Files
tapir/internal/web/views.templ
T
mathiasandClaude Opus 4.8 f66c1bcdcc
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
feat(web): real channel filter — multi-select of the user's channels
The free-text 'channel' filter was dead: it exact-matched SummaryRow.Channel,
which is just the provider ('youtube'), because videos never stored their source
channel. Now they do.

- migration 014: videos.channel_title (nullable; existing rows backfill on the
  next discovery pass, pasted videos immediately).
- discovery (NewVideos) + paste (VideoByID) populate channel_title; UpsertVideo
  persists it, preserving an existing title when an update arrives empty.
- store.DistinctChannels lists a user's channels (RLS-scoped); SummaryRow carries
  ChannelTitle via the shared projection.
- Filter: single Channel -> Channels []string, matching on ChannelTitle; the feed
  renders a multi-select of DistinctChannels (hidden until channels exist).
- migrate tests: 014 reversibility + fixed the relative-step counts in the 010/011
  up/down tests (014 shifted the topology).

TDD throughout: channel persist + distinct, adapter channel wiring, multi-channel
filter match, handler channel filter, migration up/down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:02:54 +02:00

595 lines
20 KiB
Templ

package web
import (
"fmt"
"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><a href="/auth/logout">Log out</a></nav>
</header>
<main>
{ children... }
</main>
</body>
</html>
}
// PublicLayout is the shell for unauthenticated pages (/welcome, /invite).
// Same structure as Layout but without the nav auth links — a visitor who is not
// logged in should not see "Account" or "Log out".
templ PublicLayout(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>
</header>
<main>
{ children... }
</main>
</body>
</html>
}
// WelcomePage is the public landing page (served at /welcome, outside the auth
// guard — ADR-012). Logged out: the tapir mascot, a one-line tagline, and a
// single "Get Started" CTA into the shared Dex flow (sign-in and sign-up are the
// same URL). Logged in: a greeting plus links back into the app and to log out.
templ WelcomePage(user User, loggedIn bool) {
@PublicLayout("Tapir — Watch less, know more") {
<section class="welcome">
<div class="welcome-hero">
<pre aria-hidden="true">@templ.Raw(welcomeHero)</pre>
</div>
if loggedIn {
<h1 class="welcome-title">Welcome back</h1>
if user.Email != "" {
<p class="welcome-tagline">Signed in as { user.Email }.</p>
}
<div class="welcome-cta">
<a class="btn btn-lg" href="/">Go to my Tapir</a>
<a class="btn-secondary" href="/auth/logout">Log Out</a>
</div>
} else {
<h1 class="welcome-title">Watch less, know more</h1>
<p class="welcome-tagline">
Tapir summarizes the videos your subscriptions publish, so you can
skim the gist and decide what is worth your time.
</p>
<div class="welcome-cta">
<a class="btn btn-lg" href="/auth/login">Get Started</a>
</div>
<p class="welcome-sub">Tapir is invite-only right now. If you've been invited, sign in above. New summaries land gradually Tapir fetches captions slowly to respect YouTube's limits.</p>
}
</section>
}
}
// 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(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
if hasConnected {
@pasteForm()
}
if !b.empty() || f.active() {
@filterForm(f, channels)
}
if stats.RateLimited > 0 || stats.Pending > 0 || stats.NoText > 0 {
@pipelineBar(stats)
}
if stats.RateLimited+stats.Pending > 0 {
<p class="pipeline-note muted">
Tapir fetches captions slowly on purpose, to respect YouTube's limits
new summaries land gradually. Check back tomorrow.
</p>
}
<div id="summary-list">
@summaryList(b, hasConnected)
</div>
}
}
// pipelineBar is the one-line backlog status. Counts are framed by what the user
// can read NOW ("ready"), what is waiting behind the honest caption rate limit
// ("in queue" = pending + rate-limited, never "fetching soon" — see A3/ADR-014),
// and what is permanently unreadable ("no captions").
templ pipelineBar(s PipelineStats) {
<div class="pipeline-bar">
if s.Summarized > 0 {
<span>{ fmt.Sprintf("%d ready", s.Summarized) }</span>
}
if s.RateLimited+s.Pending > 0 {
<span>{ fmt.Sprintf("%d in queue", s.RateLimited+s.Pending) }</span>
}
if s.NoText > 0 {
<span class="muted">{ fmt.Sprintf("%d no captions", s.NoText) }</span>
}
</div>
}
// pasteForm lets a connected user summarize any YouTube video by pasting its URL
// (Feature 2). The result (a video card, or an inline error) swaps into
// #paste-result; the next list refresh shows it inline. Summarization runs
// through the shared caption rate gate like every other fetch.
templ pasteForm() {
<form
class="paste"
method="post"
action="/paste"
hx-post="/paste"
hx-target="#paste-result"
hx-swap="innerHTML"
>
<label>
Summarize any video
<input type="url" name="url" placeholder="Paste a YouTube link…" required/>
</label>
<button type="submit">Add</button>
</form>
<div id="paste-result"></div>
}
templ filterForm(f Filter, channels []string) {
<form
class="filters"
method="get"
action="/"
hx-get="/"
hx-target="#summary-list"
hx-swap="innerHTML"
hx-indicator="#filter-indicator"
>
if len(channels) > 0 {
<label>
Channels
<select name="channel" multiple size="4">
for _, c := range channels {
<option value={ c } selected?={ f.HasChannel(c) }>{ c }</option>
}
</select>
</label>
}
<label class="filter-check">
<input type="checkbox" name="summarized" value="1" if f.OnlySummarized { checked }/>
Summarized only
</label>
<button type="submit" class="btn">Filter</button>
<span id="filter-indicator" class="htmx-indicator">filtering…</span>
</form>
}
// summaryList is the swappable list fragment. It leads with readable summaries +
// recent un-summarized cards (b.Main), then collapses the noise so it does not
// bury the payload (UX review B3/B4): a one-line count of caption-less videos,
// and a single disclosure holding the older un-summarized back-catalogue. Cards
// reflow to a single column on mobile; an empty list shows a friendly first-run
// state instead of a blank table.
templ summaryList(b listBuckets, hasConnected bool) {
if b.empty() {
if hasConnected {
<div class="empty empty-connected">
<strong>Your account is connected</strong>
<span>Tapir is finding your subscriptions and fetching captions summaries appear here gradually. Check back later.</span>
</div>
} else {
<div class="empty">
<strong>No videos yet</strong>
<span>Connect your YouTube account to get started.</span>
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
</div>
}
} else {
<ul class="cards">
for _, r := range b.Main {
@VideoCard(r)
}
</ul>
if b.NoCaption > 0 {
<p class="list-note muted">{ fmt.Sprintf("%d video(s) have no captions and can't be summarized.", b.NoCaption) }</p>
}
if len(b.Older) > 0 {
<details class="older-videos">
<summary>{ fmt.Sprintf("Show %d older videos — summarize on demand", len(b.Older)) }</summary>
<ul class="cards">
for _, r := range b.Older {
@VideoCard(r)
}
</ul>
</details>
}
}
}
// VideoCard is one list card, returned standalone by POST /v/{id}/summarize and
// /v/{id}/retry-now (HTMX swaps outerHTML). Five footer states, status-primary:
// 1. Summarized — preview + chip + actions; no button.
// 2. No captions (TranscriptStatus=="none") — terminal; "No transcript available"; no button.
// 3. Queued (SummarizeRequested) — "Queued · summarizing shortly"; no button.
// 4. Rate-limited — "In queue" + quiet "Summarize" → /retry-now.
// 5. Pending (else) — "Not summarized" + quiet "Summarize" → /summarize.
// States 4 and 5 use one verb ("Summarize") and one style (.btn-quiet); the
// backend side-effect difference (clear-backoff vs. set-flag) is invisible to users.
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 {
// State 1: summarized — provider chip, fallback badge, action state.
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.TranscriptStatus == "none" {
// State 2: no captions — terminal dead-end; nothing the user can do.
<span class="card-state muted">No transcript available</span>
} else if r.SummarizeRequested {
// State 3: queued — being summarized on the next pass; no scheduler jargon.
<span class="chip">Queued</span>
<span class="card-state muted">summarizing shortly</span>
} else if r.TranscriptStatus == "rate_limited" {
// State 4: rate-limited — honest "in queue" status (NOT "fetching soon",
// which oversells imminence) + a quiet nudge → retry-now handler.
<span class="card-state muted">In queue</span>
<form
method="post"
action={ retryNowURL(r.VideoID) }
hx-post={ string(retryNowURL(r.VideoID)) }
hx-target={ "#video-" + r.VideoID }
hx-swap="outerHTML"
class="card-nudge-form"
>
<button type="submit" class="btn-quiet" title="Summarize this video">Summarize</button>
</form>
} else {
// State 5: pending — discovered, not yet attempted; nudge button → summarize handler.
<span class="card-state muted">Not summarized</span>
<form
method="post"
action={ summarizeURL(r.VideoID) }
hx-post={ string(summarizeURL(r.VideoID)) }
hx-target={ "#video-" + r.VideoID }
hx-swap="outerHTML"
class="card-nudge-form"
>
<button type="submit" class="btn-quiet" title="Summarize this video">Summarize</button>
</form>
}
</div>
</li>
}
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
// grows over the dim track. The panel is aria-hidden (decorative); the
// "Summarizing…" label below carries the meaning for assistive tech.
templ TapirSpinner() {
<div class="tapir-charm" aria-hidden="true">
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
<pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
</div>
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
}
// processingCard is the in-flight summarization card. It replaces the Summarize
// button card and polls /v/{id}/status every 2s, swapping itself (outerHTML, same
// id as VideoCard) for whatever state comes back: it keeps polling while still
// processing, and the summary/queued card it is eventually replaced by carries no
// poll, so polling stops on its own when the fragment changes.
templ processingCard(r store.SummaryRow) {
<li
class="card card-processing"
id={ "video-" + r.VideoID }
hx-get={ string(statusURL(r.VideoID)) }
hx-trigger="every 2s"
hx-swap="outerHTML"
>
<div class="card-title">{ displayTitle(r) }</div>
if cardMeta(r) != "" {
<div class="card-meta">{ cardMeta(r) }</div>
}
@TapirSpinner()
</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">
<p class="back"><a href="/"> Summaries</a></p>
<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))
// Lead with the attention-saving payload: Takeaways ("is this worth my
// time?") first, then Highlights, then the full Summary last (UX review
// A8). Takeaways/Highlights are conditional, so a video without them falls
// through to the Summary leading naturally.
if len(r.Takeaways) > 0 {
<section>
<h2>Takeaways</h2>
<ul>
for _, t := range r.Takeaways {
<li>{ t }</li>
}
</ul>
</section>
}
if len(r.Highlights) > 0 {
<section>
<h2>Highlights</h2>
<ul>
for _, h := range r.Highlights {
<li>{ h }</li>
}
</ul>
</section>
}
<section>
<h2>Summary</h2>
<p class="body">{ r.Summary }</p>
</section>
</article>
}
}
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
// subject with no tapir user picks a display name 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>
<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, channelErrors []store.ChannelError, 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 new videos from about the last week as they are
discovered. Older videos stay browsable summarize them on demand.
Manual lets you pick which videos to summarize every new video appears
in your list with a Summarize button.
</p>
@summarizeModeControl(autoSummarize)
</section>
if len(channelErrors) > 0 {
<section class="channel-errors">
<h2>Unavailable channels</h2>
<p class="muted">
{ fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)) }
These may have been deleted or made private on YouTube.
</p>
<ul class="channel-error-list">
for _, ce := range channelErrors {
<li>
<span class="channel-error-name">{ ce.ChannelName }</span>
<span class="chip chip-warn">unavailable</span>
<span class="muted channel-error-since">since { ce.FirstSeen.Format("2006-01-02") }</span>
</li>
}
</ul>
</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"
>
// watched ↔ skipped are mutually exclusive (the store clears one when the
// other is set), so they read as a single segmented choice. "saved" is an
// independent toggle and sits apart (UX review C5).
<span class="segmented" role="group" aria-label="Watched or skipped">
@actionButton("watched", active["watched"])
@actionButton("skipped", active["skipped"])
</span>
@actionButton("saved", active["saved"])
</form>
}
// actionButton is one toggle button in the action group: a submit carrying its
// verb, marked active (accent fill + ✓ prefix + aria-pressed) when currently set.
templ actionButton(verb string, isActive bool) {
<button
type="submit"
name="action"
value={ verb }
class={ "action", templ.KV("active", isActive) }
aria-pressed={ ariaPressed(isActive) }
>
if isActive {
{ "✓ " + actionLabel(verb) }
} else {
{ actionLabel(verb) }
}
</button>
}