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 {
// Expand the full summary + Q&A in place (ADR-031); href is the no-JS
// fallback to the detail page, so nothing becomes JS-only.
}
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
}
}
// expandedCard is a summarized list card opened IN PLACE (ADR-031): the full
// summary body + the deeper-dive chat dock, with a collapse control back to the
// compact card. It shares the
with VideoCard so HTMX swaps it outerHTML,
// and reuses summaryBody + chatReveal so it never drifts from the detail page.
// Note: chatReveal uses a single #chat-section id, so this assumes one card open
// at a time; a per-video chat id is a follow-up if simultaneous expansion is wanted.
templ expandedCard(r store.SummaryRow, chatEnabled bool) {
@summaryBody(r)
if chatEnabled {
@chatReveal(r.VideoID)
}
}
// 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.
}
// summaryBody is the summary payload shared by the detail page and the no-JS
// chat page (so the chat page shows the same summary, not a separate view):
// metadata, embed, source, the action toggles, then the attention-saving order
// Takeaways → Highlights → Summary (UX review A8).
templ summaryBody(r store.SummaryRow) {
if detailMeta(r) != "" {
{ detailMeta(r) }
}
if r.FallbackUsed {
fallback
}
}
@ActionButtons(r.VideoID, actionSet(r.Actions))
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 }
}
// DetailPage is the full summary view: the summary payload, then (when chat is
// enabled) the deeper-dive dock (ADR-027) — a reveal that opens the chat IN PLACE
// below the summary, so the summary stays on screen as the context being asked
// about rather than being navigated away from.
templ DetailPage(r store.SummaryRow, chatEnabled bool) {
@Layout("Tapir — " + displayTitle(r)) {
@summaryBody(r)
if chatEnabled {
@chatReveal(r.VideoID)
}
}
}
// chatReveal is the CLOSED dock at the foot of the summary: a quiet affordance,
// not a loud CTA (it deepens value for a reader already here, never nudges). With
// JS it swaps itself for the open chat section in place (HTMX, summary stays
// above); without JS the same href navigates to the full chat page, which renders
// the summary alongside the chat. Either way the summary is never lost.
templ chatReveal(videoID string) {
Dig deeper — ask about this video →
}
// chatSection is the OPEN dock: heading + scope note + the chat panel, swapped in
// over the closed reveal (same #chat-section id, outerHTML). It is the HTMX reveal
// response AND the inline chat block on the no-JS chat page.
templ chatSection(v chatView) {
Ask about this video
Answers come only from this video's stored transcript — Tapir never fetches anything new here.
@chatPanel(v)
}
// ChatPage is the no-JS full-page render of the chat: the whole summary followed
// by the open chat dock, so a visitor without JS sees the same integrated view
// (summary beside the conversation) that JS users get inline via the reveal.
templ ChatPage(r store.SummaryRow, v chatView) {
@Layout("Tapir — " + displayTitle(r)) {
@summaryBody(r)
@chatSection(v)
}
}
// chatPanel is the conversation + ask form, swapped in place on each answer
// (HTMX targets #chat-panel, outerHTML). When no transcript is stored it shows the
// honest "not available" state and no form (ADR-027: never a fetch). The prior
// turns ride as hidden hq/ha fields so the ephemeral conversation survives the
// round-trip without any persisted state.
templ chatPanel(v chatView) {
if !v.Available {
Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.
} else {
if len(v.History) > 0 {
for _, t := range v.History {
{ t.Question }
{ t.Answer }
}
}
if v.Truncated {
Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.
}
if v.Error != "" {
{ v.Error }
}
}
}
// 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) {
}