The Stage-1 onboarding path: an invited user opens their emailed link, sets a password, and Tapir creates their Dex local-password account so they can log in. Mounted on root OUTSIDE Auth.Middleware — the visitor has no Dex session yet; the token in the path is the capability. handleInviteForm previews the token (no consume) and shows the form, or a clear "expired / already used" page. handleInviteSubmit validates the password BEFORE consuming the token (a typo is retryable), then claims the invite exactly once, bcrypt-hashes (cost 12), and creates the Dex account — mapping ErrPasswordExists -> "log in instead" and ErrForbidden -> "contact the administrator". Off-cluster (App.Dex nil) it degrades to a "deployed-only" message without burning the token. On success it sets an account_created flash and redirects to /auth/login. Welcome sub-text now states access is invite-only. Handlers depend on narrow ports (InvitationStore, DexPasswordCreator) so tests use fakes; cmdServe wires the store + an in-cluster dex.PasswordClient. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
496 lines
17 KiB
Templ
496 lines
17 KiB
Templ
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) {
|
|
<!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>
|
|
}
|
|
|
|
// 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") {
|
|
<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, flash string, hasConnected bool) {
|
|
@Layout("Tapir — Summaries") {
|
|
@flashBanner(flash)
|
|
@filterForm(f)
|
|
<div id="summary-list">
|
|
@summaryList(rows, hasConnected)
|
|
</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>
|
|
<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" {
|
|
<span class="chip chip-retry" title="Caption fetch was rate-limited; tapir will retry automatically.">⏳ Retrying later</span>
|
|
} 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) {
|
|
@Layout("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() {
|
|
@Layout("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) {
|
|
@Layout("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, 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>
|
|
<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>
|
|
}
|