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") {
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, autoSummarize bool) {
@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 && autoSummarize {
Tapir fetches captions slowly on purpose, to respect YouTube's limits —
new summaries land gradually. Check back tomorrow.
}
if (stats.RateLimited+stats.Pending) > 0 && !autoSummarize {
You are in Manual mode: new videos appear here but are not summarized
automatically. Use the Summarize button on the ones you want.
}
}
// 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) {
}
// 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, autoSummarize bool) {
if b.empty() {
if hasConnected {
Your account is connected
if autoSummarize {
Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.
} else {
Tapir is finding your subscriptions. You are in Manual mode, so videos appear here with a Summarize button — pick the ones you want, or switch to Automatic in your account.
}
} else {
No videos yetConnect your YouTube account to get started.
{ 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)) }
for _, r := range b.Older {
@VideoCard(r)
}
}
}
}
// 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 {
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.
Queuedsummarizing 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() {
@templ.Raw(tapirFrameHTML1)
@templ.Raw(tapirFrameHTML2)
@templ.Raw(tapirFrameHTML3)
{ tapirBarFill }
// Claude-Code / Crush-style status: playful gerunds cycle in place (CSS only,
// no JS). Decorative — aria-hidden — with one stable status line below for
// assistive tech.
Fetching captions…Chewing the cud…Munching leaves…Distilling the gist…Summarizing…
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()
}
// waitingCard is the honest rate-limited state: the click landed but YouTube is
// throttling the caption fetch, so the tapir rests and the card keeps polling
// (gently, every 30s) until the background retry lands the summary — the user
// never has to click again. Replaces the old silent revert to a Summarize button.
templ waitingCard(r store.SummaryRow) {
{ displayTitle(r) }
if cardMeta(r) != "" {
{ cardMeta(r) }
}
@templ.Raw(tapirFrameHTML2)
Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.
}
// noCaptionsCard is the terminal no-captions state: nothing to summarize, so the
// card stops (no poll, no button to click again into the same dead end).
templ noCaptionsCard(r store.SummaryRow) {
{ displayTitle(r) }
if cardMeta(r) != "" {
{ cardMeta(r) }
}
No captions available, so Tapir cannot summarize this one.
}
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
// and the action button group.
templ DetailPage(r store.SummaryRow) {
@Layout("Tapir — " + displayTitle(r)) {
}
@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
for _, t := range r.Takeaways {
{ t }
}
}
if len(r.Highlights) > 0 {
Highlights
for _, h := range r.Highlights {
{ h }
}
}
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 != "" {
{ 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.
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) {
}
// 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) {
}