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... }
} // 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: three ASCII tapir frames stacked // and cross-faded by CSS keyframes (no JS), with a muted "Summarizing…" label. // The art is aria-hidden — it is decorative; the label carries the meaning. 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 { }
    }