Three UX improvements for the pending-transcript state:
1. Summarized videos sort to top (ORDER BY (s.id IS NOT NULL) DESC, seen_at DESC)
so completed summaries are always immediately visible without filtering.
ListVideos default limit raised from 50 to 500 to show the full backlog.
2. Pipeline stats bar above the video list: '2 summarized · 256 fetching soon · 12
no captions' — computed from the unfiltered row set, hidden when everything is
summarized.
3. 'Try now' button on rate-limited cards replaces the passive 'Retrying later'
chip. POST /v/{id}/retry-now clears rate_limited_at then calls ProcessVideo
through the shared globalFetchGate — same rate limiting as the scheduler, safe
under concurrent use.
572 lines
19 KiB
Templ
572 lines
19 KiB
Templ
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) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>{ title }</title>
|
|
<script src="/static/htmx.min.js" defer></script>
|
|
@templ.Raw(styleTag)
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<a href="/" class="brand">Tapir</a>
|
|
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
|
|
</header>
|
|
<main>
|
|
{ children... }
|
|
</main>
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// 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) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>{ title }</title>
|
|
<script src="/static/htmx.min.js" defer></script>
|
|
@templ.Raw(styleTag)
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<a href="/" class="brand">Tapir</a>
|
|
</header>
|
|
<main>
|
|
{ children... }
|
|
</main>
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// 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") {
|
|
<section class="welcome">
|
|
<div class="welcome-hero">
|
|
<pre aria-hidden="true">@templ.Raw(welcomeHero)</pre>
|
|
</div>
|
|
if loggedIn {
|
|
<h1 class="welcome-title">Welcome back</h1>
|
|
if user.Email != "" {
|
|
<p class="welcome-tagline">Signed in as { user.Email }.</p>
|
|
}
|
|
<div class="welcome-cta">
|
|
<a class="btn btn-lg" href="/">Go to my Tapir</a>
|
|
<a class="btn-secondary" href="/auth/logout">Log Out</a>
|
|
</div>
|
|
} else {
|
|
<h1 class="welcome-title">Watch less, know more</h1>
|
|
<p class="welcome-tagline">
|
|
Tapir summarizes the videos your subscriptions publish, so you can
|
|
skim the gist and decide what is worth your time.
|
|
</p>
|
|
<div class="welcome-cta">
|
|
<a class="btn btn-lg" href="/auth/login">Get Started</a>
|
|
</div>
|
|
<p class="welcome-sub">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.</p>
|
|
}
|
|
</section>
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
<div class={ "flash", "flash-" + f.Kind } role="status" aria-live="polite">{ f.Message }</div>
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
<div id="summary-list">
|
|
@summaryList(rows, hasConnected)
|
|
</div>
|
|
}
|
|
}
|
|
|
|
templ pipelineBar(s PipelineStats) {
|
|
<div class="pipeline-bar">
|
|
if s.Summarized > 0 {
|
|
<span>{ fmt.Sprintf("%d summarized", s.Summarized) }</span>
|
|
}
|
|
if s.RateLimited > 0 {
|
|
<span>{ fmt.Sprintf("%d fetching soon", s.RateLimited) }</span>
|
|
}
|
|
if s.Pending > 0 {
|
|
<span>{ fmt.Sprintf("%d pending", s.Pending) }</span>
|
|
}
|
|
if s.NoText > 0 {
|
|
<span class="muted">{ fmt.Sprintf("%d no captions", s.NoText) }</span>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
templ filterForm(f Filter) {
|
|
<form
|
|
class="filters"
|
|
method="get"
|
|
action="/"
|
|
hx-get="/"
|
|
hx-target="#summary-list"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#filter-indicator"
|
|
>
|
|
<label>Channel <input type="text" name="channel" value={ f.Channel } placeholder="any"/></label>
|
|
<label>From <input type="date" name="from" value={ f.From }/></label>
|
|
<label>To <input type="date" name="to" value={ f.To }/></label>
|
|
<label class="filter-check">
|
|
<input type="checkbox" name="summarized" value="1" if f.OnlySummarized { checked }/>
|
|
Summarized only
|
|
</label>
|
|
<button type="submit" class="btn">Filter</button>
|
|
<span id="filter-indicator" class="htmx-indicator">filtering…</span>
|
|
</form>
|
|
}
|
|
|
|
// 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 {
|
|
<div class="empty empty-connected">
|
|
<strong>Your YouTube account is connected!</strong>
|
|
<span>Run <code>tapir run</code> to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.</span>
|
|
</div>
|
|
} else {
|
|
<div class="empty">
|
|
<strong>No videos yet</strong>
|
|
<span>Connect your YouTube account to get started.</span>
|
|
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
|
|
</div>
|
|
}
|
|
} else {
|
|
<ul class="cards">
|
|
for _, r := range rows {
|
|
@VideoCard(r)
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
|
if r.Summarized {
|
|
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
|
} else {
|
|
<div class="card-title">{ displayTitle(r) }</div>
|
|
}
|
|
if cardMeta(r) != "" {
|
|
<div class="card-meta">{ cardMeta(r) }</div>
|
|
}
|
|
if r.Summarized {
|
|
if p := previewText(r.Summary, 160); p != "" {
|
|
<div class="card-preview">{ p }</div>
|
|
}
|
|
}
|
|
<div class="card-foot">
|
|
if r.Summarized {
|
|
if r.AIProvider != "" {
|
|
<span class="chip">{ r.AIProvider }</span>
|
|
}
|
|
if r.FallbackUsed {
|
|
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
|
}
|
|
if len(r.Actions) > 0 {
|
|
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
|
}
|
|
} else if r.TranscriptStatus == "rate_limited" {
|
|
<form
|
|
method="post"
|
|
action={ retryNowURL(r.VideoID) }
|
|
hx-post={ string(retryNowURL(r.VideoID)) }
|
|
hx-target={ "#video-" + r.VideoID }
|
|
hx-swap="outerHTML"
|
|
class="retry-form"
|
|
>
|
|
<button type="submit" class="btn-retry" title="Fetch transcript now through the shared rate gate">Try now</button>
|
|
</form>
|
|
} else if r.SummarizeRequested {
|
|
<span class="chip">Queued</span>
|
|
<span class="card-state muted">waiting for the next run</span>
|
|
} else {
|
|
<form
|
|
method="post"
|
|
action={ summarizeURL(r.VideoID) }
|
|
hx-post={ string(summarizeURL(r.VideoID)) }
|
|
hx-target={ "#video-" + r.VideoID }
|
|
hx-swap="outerHTML"
|
|
>
|
|
<button type="submit" class="btn-secondary">Summarize</button>
|
|
</form>
|
|
}
|
|
</div>
|
|
</li>
|
|
}
|
|
|
|
// 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() {
|
|
<div class="tapir-charm" aria-hidden="true">
|
|
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
|
|
<pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
|
|
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
|
|
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
|
|
</div>
|
|
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
|
|
}
|
|
|
|
// 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) {
|
|
<li
|
|
class="card card-processing"
|
|
id={ "video-" + r.VideoID }
|
|
hx-get={ string(statusURL(r.VideoID)) }
|
|
hx-trigger="every 2s"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<div class="card-title">{ displayTitle(r) }</div>
|
|
if cardMeta(r) != "" {
|
|
<div class="card-meta">{ cardMeta(r) }</div>
|
|
}
|
|
@TapirSpinner()
|
|
</li>
|
|
}
|
|
|
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
|
// and the action button group.
|
|
templ DetailPage(r store.SummaryRow) {
|
|
@Layout("Tapir — " + displayTitle(r)) {
|
|
<article class="detail">
|
|
<h1>{ displayTitle(r) }</h1>
|
|
<p class="meta">
|
|
if detailMeta(r) != "" {
|
|
<span>{ detailMeta(r) }</span>
|
|
}
|
|
if r.FallbackUsed {
|
|
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
|
}
|
|
</p>
|
|
if url, ok := embedURL(r.ProviderVideoID); ok {
|
|
<div class="embed">
|
|
<iframe
|
|
src={ url }
|
|
title={ displayTitle(r) }
|
|
loading="lazy"
|
|
referrerpolicy="strict-origin-when-cross-origin"
|
|
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowfullscreen
|
|
></iframe>
|
|
</div>
|
|
}
|
|
if r.URL != "" {
|
|
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
|
}
|
|
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
|
<section>
|
|
<h2>Summary</h2>
|
|
<p class="body">{ r.Summary }</p>
|
|
</section>
|
|
if len(r.Highlights) > 0 {
|
|
<section>
|
|
<h2>Highlights</h2>
|
|
<ul>
|
|
for _, h := range r.Highlights {
|
|
<li>{ h }</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
if len(r.Takeaways) > 0 {
|
|
<section>
|
|
<h2>Takeaways</h2>
|
|
<ul>
|
|
for _, t := range r.Takeaways {
|
|
<li>{ t }</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// 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") {
|
|
<article class="register">
|
|
<h1>Complete your registration</h1>
|
|
if email != "" {
|
|
<p class="meta">Signed in as { email }.</p>
|
|
}
|
|
<p>Choose a display name to finish setting up your Tapir account.</p>
|
|
if errMsg != "" {
|
|
<p class="error" role="alert">{ errMsg }</p>
|
|
}
|
|
<form method="post" action="/register" class="register-form">
|
|
<label>
|
|
Display name
|
|
<input type="text" name="display_name" required autofocus/>
|
|
</label>
|
|
<label class="checkbox">
|
|
<input type="checkbox" name="accept_terms" value="yes" required/>
|
|
I accept the terms of use
|
|
</label>
|
|
<button type="submit" class="btn">Register</button>
|
|
</form>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// 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") {
|
|
<article class="register">
|
|
<h1>Set up your Tapir account</h1>
|
|
<p class="meta">Invitation for { email }.</p>
|
|
<p>Choose a password to finish creating your account. You'll then log in with this email and password.</p>
|
|
if errMsg != "" {
|
|
<p class="error" role="alert">{ errMsg }</p>
|
|
}
|
|
<form method="post" action={ inviteURL(token) } class="register-form">
|
|
<label>
|
|
Email
|
|
<input type="email" name="email" value={ email } readonly/>
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password"/>
|
|
</label>
|
|
<label>
|
|
Confirm password
|
|
<input type="password" name="password_confirm" minlength="8" required autocomplete="new-password"/>
|
|
</label>
|
|
<button type="submit" class="btn">Create my account</button>
|
|
</form>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// 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") {
|
|
<article class="register">
|
|
<h1>This invite link is no longer valid</h1>
|
|
<p>This invitation has expired or has already been used. Ask for a fresh invite link, or log in if you already have an account.</p>
|
|
<p><a class="btn" href="/auth/login">Log in</a></p>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// 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") {
|
|
<article class="register">
|
|
<h1>Invitation</h1>
|
|
<p>{ message }</p>
|
|
if showLogin {
|
|
<p><a class="btn" href="/auth/login">Log in</a></p>
|
|
}
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
<article class="account">
|
|
<h1>Account</h1>
|
|
<dl class="account-meta">
|
|
<dt>Display name</dt>
|
|
<dd>{ displayNameOr(displayName) }</dd>
|
|
if email != "" {
|
|
<dt>Signed in as</dt>
|
|
<dd>{ email }</dd>
|
|
}
|
|
</dl>
|
|
<section>
|
|
<h2>Summarization</h2>
|
|
<p class="muted">
|
|
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.
|
|
</p>
|
|
@summarizeModeControl(autoSummarize)
|
|
</section>
|
|
if len(channelErrors) > 0 {
|
|
<section class="channel-errors">
|
|
<h2>Unavailable channels</h2>
|
|
<p class="muted">
|
|
{ fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)) }
|
|
These may have been deleted or made private on YouTube.
|
|
</p>
|
|
<ul class="channel-error-list">
|
|
for _, ce := range channelErrors {
|
|
<li>
|
|
<span class="channel-error-name">{ ce.ChannelName }</span>
|
|
<span class="chip chip-warn">unavailable</span>
|
|
<span class="muted channel-error-since">since { ce.FirstSeen.Format("2006-01-02") }</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
<section>
|
|
<h2>Connected accounts</h2>
|
|
if len(conns) == 0 {
|
|
<p class="muted">No connected video accounts yet.</p>
|
|
} else {
|
|
<ul class="conn-list">
|
|
for _, c := range conns {
|
|
<li class="conn">
|
|
<div class="conn-main">
|
|
<span class="conn-provider">{ providerLabel(c.Provider) }</span>
|
|
if c.ProviderAccount != "" {
|
|
<span class="muted">{ c.ProviderAccount }</span>
|
|
}
|
|
<span class="chip">{ c.Status }</span>
|
|
</div>
|
|
<div class="conn-meta muted">connected { c.ConnectedAt.Format("2006-01-02") }</div>
|
|
<form method="post" action={ disconnectURL(c.Provider) }>
|
|
<button type="submit" class="btn-secondary">Disconnect</button>
|
|
</form>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
if !hasYouTube(conns) {
|
|
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
|
|
}
|
|
</section>
|
|
<section class="danger-zone">
|
|
<h2>Delete account</h2>
|
|
<p class="muted">
|
|
Permanently remove your Tapir account and all of its data — summaries,
|
|
watch/skip/save actions, and connected accounts. This cannot be undone.
|
|
</p>
|
|
<details class="confirm-delete">
|
|
<summary class="btn-danger">Delete account…</summary>
|
|
<div class="confirm-body">
|
|
<p>This permanently deletes your account and all data. Are you sure?</p>
|
|
<form method="post" action="/account/delete">
|
|
<button type="submit" class="btn-danger">Yes, permanently delete my account</button>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
<div id="summarize-mode" class="summarize-mode">
|
|
<p>Current mode: <strong>{ summarizeModeLabel(auto) }</strong></p>
|
|
<form
|
|
method="post"
|
|
action="/account/summarize-mode"
|
|
hx-post="/account/summarize-mode"
|
|
hx-target="#summarize-mode"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<input type="hidden" name="enabled" value={ boolStr(!auto) }/>
|
|
<button type="submit" class="btn-secondary">{ summarizeModeToggleLabel(auto) }</button>
|
|
</form>
|
|
</div>
|
|
}
|
|
|
|
// 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) {
|
|
<form
|
|
id="action-buttons"
|
|
class="actions"
|
|
method="post"
|
|
action={ actionURL(videoID) }
|
|
hx-post={ string(actionURL(videoID)) }
|
|
hx-target="#action-buttons"
|
|
hx-swap="outerHTML"
|
|
>
|
|
for _, v := range actionVerbs {
|
|
<button
|
|
type="submit"
|
|
name="action"
|
|
value={ v }
|
|
class={ "action", templ.KV("active", active[v]) }
|
|
aria-pressed={ ariaPressed(active[v]) }
|
|
>
|
|
if active[v] {
|
|
{ "✓ " + actionLabel(v) }
|
|
} else {
|
|
{ actionLabel(v) }
|
|
}
|
|
</button>
|
|
}
|
|
</form>
|
|
}
|