package web import ( "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... }
} // 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) { @Layout("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

New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.

}
} } // 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, flash string) { @Layout("Tapir — Summaries") { @flashBanner(flash) @filterForm(f)
@summaryList(rows)
} } 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) { if len(rows) == 0 {
No videos yet Videos appear here as your subscriptions are processed — run tapir run to fetch them. In manual mode, use the Summarize button to queue one.
} 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.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 != "" { }
    } } // 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, 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)

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