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) { { title } @templ.Raw(styleTag)
Tapir
{ children... }
} // 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) { { title } @templ.Raw(styleTag)
Tapir
{ children... }
} // 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") {
if loggedIn {

Welcome back

if user.Email != "" {

Signed in as { user.Email }.

}
Go to my Tapir Log Out
} else {

Watch less, know more

Tapir summarizes the videos your subscriptions publish, so you can skim the gist and decide what is worth your time.

Get Started

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.

}
} } // 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 {
{ f.Message }
} } // 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 {

Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually. Check back tomorrow.

}
@summaryList(b, hasConnected)
} } // 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) {
if s.Summarized > 0 { { fmt.Sprintf("%d ready", s.Summarized) } } if s.RateLimited+s.Pending > 0 { { fmt.Sprintf("%d in queue", s.RateLimited+s.Pending) } } if s.NoText > 0 { { fmt.Sprintf("%d no captions", s.NoText) } }
} // 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() {
} templ filterForm(f Filter, channels []string) {
if len(channels) > 0 { } filtering…
} // 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 {
Your account is connected Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.
} else {
No videos yet Connect your YouTube account to get started.

Connect YouTube

} } else { if b.NoCaption > 0 {

{ fmt.Sprintf("%d video(s) have no captions and can't be summarized.", b.NoCaption) }

} if len(b.Older) > 0 {
{ fmt.Sprintf("Show %d older videos — summarize on demand", len(b.Older)) }
} } } // 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) {
  • if r.Summarized {
    { displayTitle(r) }
    } else {
    { displayTitle(r) }
    } if cardMeta(r) != "" {
    { cardMeta(r) }
    } if r.Summarized { if p := previewText(r.Summary, 160); p != "" {
    { p }
    } }
    if r.Summarized { // State 1: summarized — provider chip, fallback badge, action state. if r.AIProvider != "" { { r.AIProvider } } if r.FallbackUsed { fallback } if len(r.Actions) > 0 { { strings.Join(r.Actions, ", ") } } } else if r.TranscriptStatus == "none" { // State 2: no captions — terminal dead-end; nothing the user can do. No transcript available } else if r.SummarizeRequested { // State 3: queued — being summarized on the next pass; no scheduler jargon. Queued summarizing shortly } 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. In queue
    } else { // State 5: pending — discovered, not yet attempted; nudge button → summarize handler. Not summarized
    }
  • } // 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() {

    Summarizing…

    } // 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) {
  • { displayTitle(r) }
    if cardMeta(r) != "" {
    { cardMeta(r) }
    } @TapirSpinner()
  • } // DetailPage is the full summary view: text, highlights, takeaways, metadata, // and the action button group. templ DetailPage(r store.SummaryRow) { @Layout("Tapir — " + displayTitle(r)) {

    ← Summaries

    { displayTitle(r) }

    if detailMeta(r) != "" { { detailMeta(r) } } if r.FallbackUsed { fallback }

    if url, ok := embedURL(r.ProviderVideoID); ok {
    } if r.URL != "" {

    watch on source ↗

    } @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 {

    Takeaways

    } if len(r.Highlights) > 0 {

    Highlights

    }

    Summary

    { r.Summary }

    } } // 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") {

    Complete your registration

    if email != "" {

    Signed in as { email }.

    }

    Choose a display name to finish setting up your Tapir account.

    if errMsg != "" { }
    } } // 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)

    Account

    Display name
    { displayNameOr(displayName) }
    if email != "" {
    Signed in as
    { email }
    }

    Summarization

    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.

    @summarizeModeControl(autoSummarize)
    if len(channelErrors) > 0 {

    Unavailable channels

    { fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)) } These may have been deleted or made private on YouTube.

    }

    Connected accounts

    if len(conns) == 0 {

    No connected video accounts yet.

    } else { } if !hasYouTube(conns) {

    Connect YouTube

    }

    Delete account

    Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

    Delete account…

    This permanently deletes your account and all data. Are you sure?

    } } // 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) {

    Current mode: { summarizeModeLabel(auto) }

    } // 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) {
    // 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). @actionButton("watched", active["watched"]) @actionButton("skipped", active["skipped"]) @actionButton("saved", active["saved"])
    } // 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) { }