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

Access is by invitation. If you have an invite link, it will set up your account automatically. Returning users with credentials can log in above.

}
} } // 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(rows []store.SummaryRow, f Filter, stats PipelineStats, flash string, hasConnected bool) { @Layout("Tapir — Summaries") { @flashBanner(flash) @filterForm(f) if stats.RateLimited > 0 || stats.Pending > 0 || stats.NoText > 0 { @pipelineBar(stats) }
@summaryList(rows, hasConnected)
} } templ pipelineBar(s PipelineStats) {
if s.Summarized > 0 { { fmt.Sprintf("%d summarized", s.Summarized) } } if s.RateLimited > 0 { { fmt.Sprintf("%d fetching soon", s.RateLimited) } } if s.Pending > 0 { { fmt.Sprintf("%d pending", s.Pending) } } if s.NoText > 0 { { fmt.Sprintf("%d no captions", s.NoText) } }
} templ filterForm(f Filter) {
filtering…
} // 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, hasConnected bool) { if len(rows) == 0 { if hasConnected {
Your YouTube account is connected! Run tapir run to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.
} else {
No videos yet Connect your YouTube account to get started.

Connect YouTube

} } else { } } // 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) {
  • 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 { if r.AIProvider != "" { { r.AIProvider } } if r.FallbackUsed { fallback } if len(r.Actions) > 0 { { strings.Join(r.Actions, ", ") } } } else if r.TranscriptStatus == "rate_limited" {
    } else if r.SummarizeRequested { Queued waiting for the next run } else {
    }
  • } // 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)) {

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

    Summary

    { r.Summary }

    if len(r.Highlights) > 0 {

    Highlights

    } if len(r.Takeaways) > 0 {

    Takeaways

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

    Complete your registration

    if email != "" {

    Signed in as { email }.

    }

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

    if errMsg != "" { }
    } } // InvitePage is the public set-password form an invited user reaches via their // emailed /invite/{token} link. The email is shown read-only (it is fixed by the // invite, not chosen here); the visitor sets a password to create their account. // errMsg, when set, reports a validation problem on the prior submit. No auth // chrome (header nav) is appropriate — the visitor has no session yet — but the // shared Layout keeps the look consistent. templ InvitePage(email, token, errMsg string) { @PublicLayout("Tapir — Set your password") {

    Set up your Tapir account

    Invitation for { email }.

    Choose a password to finish creating your account. You'll then log in with this email and password.

    if errMsg != "" { }
    } } // InviteInvalidPage is shown when an invite token is missing, expired, or already // used — a dead-end with no form, so a stale or replayed link reads clearly. templ InviteInvalidPage() { @PublicLayout("Tapir — Invitation") {

    This invite link is no longer valid

    This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.

    Log in

    } } // InviteNoticePage is a terminal message after a submit that neither succeeded nor // is a retryable validation error (account already exists, RBAC missing, or the // dev "deployed-only" degrade). showLogin adds a log-in CTA where that is the // natural next step. templ InviteNoticePage(message string, showLogin bool) { @PublicLayout("Tapir — Invitation") {

    Invitation

    { message }

    if showLogin {

    Log in

    }
    } } // 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 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.

    @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) {
    for _, v := range actionVerbs { }
    }