The UI read flat and boring. Reskin to one charm/TUI-inspired layout in two palettes (CSS custom properties): a warm "reader" light theme (sketch B) and a "cozy terminal" dark theme (sketch C). - Palette chosen in cascade order: :root light default; an OS-preference dark block scoped to :root:not([data-theme]) so it applies only absent an explicit choice; and :root[data-theme="dark"|"light"] set by a header toggle that outranks the media query by specificity and persists in localStorage (guarded, degrades to OS default). A <head> init script applies the stored choice before paint, so no flash of the wrong palette. - Charm touches via existing classes (no templ structure churn): monospace meta lines, accent uppercase section dividers with a trailing rule, pill buttons, a lifted/accent-edged expanded card. - Error/danger shades become --err-* tokens so they follow the theme, replacing three per-block prefers-color-scheme dark overrides. - Theme toggle wired into Layout and PublicLayout headers. BDD: docs/use-cases/visual_theme.feature un-pended, mapped in scenarioCoverage. TDD: internal/web/visual_theme_test.go (palettes, OS default, persisted toggle, expanded-card embed). Verified light+dark on list/reader/welcome via web-shot. Sketches kept as the design record. ui-spec.md as-built row added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
799 lines
28 KiB
Templ
799 lines
28 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>
|
|
@templ.Raw(themeScriptTag)
|
|
<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>@templ.Raw(themeToggleButton)</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>
|
|
@templ.Raw(themeScriptTag)
|
|
<script src="/static/htmx.min.js" defer></script>
|
|
@templ.Raw(styleTag)
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<a href="/" class="brand">Tapir</a>
|
|
<nav class="nav">@templ.Raw(themeToggleButton)</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) {
|
|
@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">Tapir is invite-only right now. If you've been invited, sign in above. New summaries land gradually — Tapir fetches captions slowly to respect YouTube's limits.</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(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool) {
|
|
@Layout("Tapir — Summaries") {
|
|
@flashBanner(flash)
|
|
if hasConnected {
|
|
@pasteForm()
|
|
}
|
|
if !b.empty() || f.active() {
|
|
@filterForm(f, channels)
|
|
}
|
|
if stats.RateLimited > 0 || stats.Pending > 0 || stats.NoText > 0 {
|
|
@pipelineBar(stats)
|
|
}
|
|
if (stats.RateLimited+stats.Pending) > 0 && autoSummarize {
|
|
<p class="pipeline-note muted">
|
|
Tapir fetches captions slowly on purpose, to respect YouTube's limits —
|
|
new summaries land gradually. Check back tomorrow.
|
|
</p>
|
|
}
|
|
if (stats.RateLimited+stats.Pending) > 0 && !autoSummarize {
|
|
<p class="pipeline-note muted">
|
|
You are in Manual mode: new videos appear here but are not summarized
|
|
automatically. Use the Summarize button on the ones you want.
|
|
</p>
|
|
<p class="pipeline-note muted">
|
|
<a href="/account">Switch to Automatic</a> to have new videos summarized for you.
|
|
</p>
|
|
}
|
|
<div id="summary-list">
|
|
@summaryList(b, hasConnected, autoSummarize)
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// pipelineBar is the one-line backlog status. Counts are framed by what the user
|
|
// can read NOW ("ready"), what is waiting behind the honest caption rate limit
|
|
// ("in queue" = pending + rate-limited, never "fetching soon" — see A3/ADR-014),
|
|
// and what is permanently unreadable ("no captions").
|
|
templ pipelineBar(s PipelineStats) {
|
|
<div class="pipeline-bar">
|
|
if s.Summarized > 0 {
|
|
<span>{ fmt.Sprintf("%d ready", s.Summarized) }</span>
|
|
}
|
|
if s.RateLimited+s.Pending > 0 {
|
|
<span>{ fmt.Sprintf("%d in queue", s.RateLimited+s.Pending) }</span>
|
|
}
|
|
if s.NoText > 0 {
|
|
<span class="muted">{ fmt.Sprintf("%d no captions", s.NoText) }</span>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// pasteForm lets a connected user summarize any YouTube video by pasting its URL
|
|
// (Feature 2). The result (a video card, or an inline error) swaps into
|
|
// #paste-result; the next list refresh shows it inline. Summarization runs
|
|
// through the shared caption rate gate like every other fetch.
|
|
templ pasteForm() {
|
|
<form
|
|
class="paste"
|
|
method="post"
|
|
action="/paste"
|
|
hx-post="/paste"
|
|
hx-target="#paste-result"
|
|
hx-swap="innerHTML"
|
|
>
|
|
<label>
|
|
Summarize any video
|
|
<input type="url" name="url" placeholder="Paste a YouTube link…" required/>
|
|
</label>
|
|
<button type="submit">Add</button>
|
|
</form>
|
|
<div id="paste-result"></div>
|
|
}
|
|
|
|
templ filterForm(f Filter, channels []string) {
|
|
<form
|
|
class="filters"
|
|
method="get"
|
|
action="/"
|
|
hx-get="/"
|
|
hx-target="#summary-list"
|
|
hx-swap="innerHTML"
|
|
hx-indicator="#filter-indicator"
|
|
>
|
|
if len(channels) > 0 {
|
|
<label>
|
|
Channels
|
|
<select name="channel" multiple size="4">
|
|
for _, c := range channels {
|
|
<option value={ c } selected?={ f.HasChannel(c) }>{ c }</option>
|
|
}
|
|
</select>
|
|
</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. It leads with readable summaries +
|
|
// recent un-summarized cards (b.Main), then collapses the noise so it does not
|
|
// bury the payload (UX review B3/B4): a one-line count of caption-less videos,
|
|
// and a single disclosure holding the older un-summarized back-catalogue. Cards
|
|
// reflow to a single column on mobile; an empty list shows a friendly first-run
|
|
// state instead of a blank table.
|
|
templ summaryList(b listBuckets, hasConnected bool, autoSummarize bool) {
|
|
if b.empty() {
|
|
if hasConnected {
|
|
<div class="empty empty-connected">
|
|
<strong>Your account is connected</strong>
|
|
if autoSummarize {
|
|
<span>Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.</span>
|
|
} else {
|
|
<span>Tapir is finding your subscriptions. You are in Manual mode, so videos appear here with a Summarize button — pick the ones you want, or switch to Automatic in your account.</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 b.Main {
|
|
@VideoCard(r)
|
|
}
|
|
</ul>
|
|
if b.NoCaption > 0 {
|
|
<p class="list-note muted">{ fmt.Sprintf("%d video(s) have no captions and can't be summarized.", b.NoCaption) }</p>
|
|
}
|
|
if len(b.Older) > 0 {
|
|
<details class="older-videos">
|
|
<summary>{ fmt.Sprintf("Show %d older videos — summarize on demand", len(b.Older)) }</summary>
|
|
<ul class="cards">
|
|
for _, r := range b.Older {
|
|
@VideoCard(r)
|
|
}
|
|
</ul>
|
|
</details>
|
|
}
|
|
}
|
|
}
|
|
|
|
// VideoCard is one list card, returned standalone by POST /v/{id}/summarize and
|
|
// /v/{id}/retry-now (HTMX swaps outerHTML). Five footer states, status-primary:
|
|
// 1. Summarized — preview + chip + actions; no button.
|
|
// 2. No captions (TranscriptStatus=="none") — terminal; "No transcript available"; no button.
|
|
// 3. Queued (SummarizeRequested) — "Queued · summarizing shortly"; no button.
|
|
// 4. Rate-limited — "In queue" + quiet "Summarize" → /retry-now.
|
|
// 5. Pending (else) — "Not summarized" + quiet "Summarize" → /summarize.
|
|
// States 4 and 5 use one verb ("Summarize") and one style (.btn-quiet); the
|
|
// backend side-effect difference (clear-backoff vs. set-flag) is invisible to users.
|
|
templ VideoCard(r store.SummaryRow) {
|
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
|
if r.Summarized {
|
|
// Expand the full summary + Q&A in place (ADR-031); href is the no-JS
|
|
// fallback to the detail page, so nothing becomes JS-only.
|
|
<div class="card-title">
|
|
<a
|
|
href={ videoURL(r.VideoID) }
|
|
hx-get={ string(expandURL(r.VideoID)) }
|
|
hx-target={ "#video-" + r.VideoID }
|
|
hx-swap="outerHTML"
|
|
>{ 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 {
|
|
// State 1: summarized — provider chip, fallback badge, action state.
|
|
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 == "none" {
|
|
// State 2: no captions — terminal dead-end; nothing the user can do.
|
|
<span class="card-state muted">No transcript available</span>
|
|
} else if r.SummarizeRequested {
|
|
// State 3: queued — being summarized on the next pass; no scheduler jargon.
|
|
<span class="chip">Queued</span>
|
|
<span class="card-state muted">summarizing shortly</span>
|
|
} else if r.TranscriptStatus == "rate_limited" {
|
|
// State 4: rate-limited — honest "in queue" status (NOT "fetching soon",
|
|
// which oversells imminence) + a quiet nudge → retry-now handler.
|
|
<span class="card-state muted">In queue</span>
|
|
<form
|
|
method="post"
|
|
action={ retryNowURL(r.VideoID) }
|
|
hx-post={ string(retryNowURL(r.VideoID)) }
|
|
hx-target={ "#video-" + r.VideoID }
|
|
hx-swap="outerHTML"
|
|
class="card-nudge-form"
|
|
>
|
|
<button type="submit" class="btn-quiet" title="Summarize this video">Summarize</button>
|
|
</form>
|
|
} else {
|
|
// State 5: pending — discovered, not yet attempted; nudge button → summarize handler.
|
|
<span class="card-state muted">Not summarized</span>
|
|
<form
|
|
method="post"
|
|
action={ summarizeURL(r.VideoID) }
|
|
hx-post={ string(summarizeURL(r.VideoID)) }
|
|
hx-target={ "#video-" + r.VideoID }
|
|
hx-swap="outerHTML"
|
|
class="card-nudge-form"
|
|
>
|
|
<button type="submit" class="btn-quiet" title="Summarize this video">Summarize</button>
|
|
</form>
|
|
}
|
|
</div>
|
|
</li>
|
|
}
|
|
|
|
// expandedCard is a summarized list card opened IN PLACE (ADR-031): the full
|
|
// summary body + the deeper-dive chat dock, with a collapse control back to the
|
|
// compact card. It shares the <li id> with VideoCard so HTMX swaps it outerHTML,
|
|
// and reuses summaryBody + chatReveal so it never drifts from the detail page.
|
|
// Note: chatReveal uses a single #chat-section id, so this assumes one card open
|
|
// at a time; a per-video chat id is a follow-up if simultaneous expansion is wanted.
|
|
templ expandedCard(r store.SummaryRow, chatEnabled bool) {
|
|
<li class="card card-expanded" id={ "video-" + r.VideoID }>
|
|
<div class="card-expanded-head">
|
|
<span class="card-title">{ displayTitle(r) }</span>
|
|
<a
|
|
href={ videoURL(r.VideoID) }
|
|
hx-get={ string(cardURL(r.VideoID)) }
|
|
hx-target={ "#video-" + r.VideoID }
|
|
hx-swap="outerHTML"
|
|
class="card-collapse"
|
|
title="Collapse"
|
|
>collapse ↑</a>
|
|
</div>
|
|
@summaryBody(r)
|
|
if chatEnabled {
|
|
@chatReveal(r.VideoID)
|
|
}
|
|
</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>
|
|
// Claude-Code / Crush-style status: playful gerunds cycle in place (CSS only,
|
|
// no JS). Decorative — aria-hidden — with one stable status line below for
|
|
// assistive tech.
|
|
<p class="tapir-verbs" aria-hidden="true">
|
|
<span class="tv1"><em>Fetching captions…</em></span>
|
|
<span class="tv2"><em>Chewing the cud…</em></span>
|
|
<span class="tv3"><em>Munching leaves…</em></span>
|
|
<span class="tv4"><em>Distilling the gist…</em></span>
|
|
<span class="tv5"><em>Summarizing…</em></span>
|
|
</p>
|
|
<p class="sr-only" role="status" aria-live="polite">Summarizing…</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>
|
|
}
|
|
|
|
// waitingCard is the honest rate-limited state: the click landed but YouTube is
|
|
// throttling the caption fetch, so the tapir rests and the card keeps polling
|
|
// (gently, every 30s) until the background retry lands the summary — the user
|
|
// never has to click again. Replaces the old silent revert to a Summarize button.
|
|
templ waitingCard(r store.SummaryRow) {
|
|
<li
|
|
class="card card-waiting"
|
|
id={ "video-" + r.VideoID }
|
|
hx-get={ string(statusURL(r.VideoID)) }
|
|
hx-trigger="every 30s"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<div class="card-title">{ displayTitle(r) }</div>
|
|
if cardMeta(r) != "" {
|
|
<div class="card-meta">{ cardMeta(r) }</div>
|
|
}
|
|
<div class="tapir-charm tapir-resting" aria-hidden="true">
|
|
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML2)</pre>
|
|
</div>
|
|
<p class="tapir-label" role="status" aria-live="polite">
|
|
Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.
|
|
</p>
|
|
</li>
|
|
}
|
|
|
|
// noCaptionsCard is the terminal no-captions state: nothing to summarize, so the
|
|
// card stops (no poll, no button to click again into the same dead end).
|
|
templ noCaptionsCard(r store.SummaryRow) {
|
|
<li class="card card-no-captions" id={ "video-" + r.VideoID }>
|
|
<div class="card-title">{ displayTitle(r) }</div>
|
|
if cardMeta(r) != "" {
|
|
<div class="card-meta">{ cardMeta(r) }</div>
|
|
}
|
|
<p class="card-state muted">No captions available, so Tapir cannot summarize this one.</p>
|
|
</li>
|
|
}
|
|
|
|
// summaryBody is the summary payload shared by the detail page and the no-JS
|
|
// chat page (so the chat page shows the same summary, not a separate view):
|
|
// metadata, embed, source, the action toggles, then the attention-saving order
|
|
// Takeaways → Highlights → Summary (UX review A8).
|
|
templ summaryBody(r store.SummaryRow) {
|
|
<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))
|
|
if len(r.Takeaways) > 0 {
|
|
<section>
|
|
<h2>Takeaways</h2>
|
|
<ul>
|
|
for _, t := range r.Takeaways {
|
|
<li>{ t }</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
if len(r.Highlights) > 0 {
|
|
<section>
|
|
<h2>Highlights</h2>
|
|
<ul>
|
|
for _, h := range r.Highlights {
|
|
<li>{ h }</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
<section>
|
|
<h2>Summary</h2>
|
|
<p class="body">{ r.Summary }</p>
|
|
</section>
|
|
}
|
|
|
|
// DetailPage is the full summary view: the summary payload, then (when chat is
|
|
// enabled) the deeper-dive dock (ADR-027) — a reveal that opens the chat IN PLACE
|
|
// below the summary, so the summary stays on screen as the context being asked
|
|
// about rather than being navigated away from.
|
|
templ DetailPage(r store.SummaryRow, chatEnabled bool) {
|
|
@Layout("Tapir — " + displayTitle(r)) {
|
|
<article class="detail">
|
|
<p class="back"><a href="/">← Summaries</a></p>
|
|
<h1>{ displayTitle(r) }</h1>
|
|
@summaryBody(r)
|
|
if chatEnabled {
|
|
@chatReveal(r.VideoID)
|
|
}
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// chatReveal is the CLOSED dock at the foot of the summary: a quiet affordance,
|
|
// not a loud CTA (it deepens value for a reader already here, never nudges). With
|
|
// JS it swaps itself for the open chat section in place (HTMX, summary stays
|
|
// above); without JS the same href navigates to the full chat page, which renders
|
|
// the summary alongside the chat. Either way the summary is never lost.
|
|
templ chatReveal(videoID string) {
|
|
<section id="chat-section" class="chat-dock">
|
|
<a
|
|
class="btn-secondary chat-open"
|
|
href={ chatURL(videoID) }
|
|
hx-get={ string(chatURL(videoID)) }
|
|
hx-target="#chat-section"
|
|
hx-swap="outerHTML"
|
|
>
|
|
Dig deeper — ask about this video →
|
|
</a>
|
|
</section>
|
|
}
|
|
|
|
// chatSection is the OPEN dock: heading + scope note + the chat panel, swapped in
|
|
// over the closed reveal (same #chat-section id, outerHTML). It is the HTMX reveal
|
|
// response AND the inline chat block on the no-JS chat page.
|
|
templ chatSection(v chatView) {
|
|
<section id="chat-section" class="chat-dock chat-dock-open">
|
|
<h2 class="chat-heading">Ask about this video</h2>
|
|
<p class="chat-scope muted">Answers come only from this video's stored transcript — Tapir never fetches anything new here.</p>
|
|
@chatPanel(v)
|
|
</section>
|
|
}
|
|
|
|
// ChatPage is the no-JS full-page render of the chat: the whole summary followed
|
|
// by the open chat dock, so a visitor without JS sees the same integrated view
|
|
// (summary beside the conversation) that JS users get inline via the reveal.
|
|
templ ChatPage(r store.SummaryRow, v chatView) {
|
|
@Layout("Tapir — " + displayTitle(r)) {
|
|
<article class="detail">
|
|
<p class="back"><a href="/">← Summaries</a></p>
|
|
<h1>{ displayTitle(r) }</h1>
|
|
@summaryBody(r)
|
|
@chatSection(v)
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// chatPanel is the conversation + ask form, swapped in place on each answer
|
|
// (HTMX targets #chat-panel, outerHTML). When no transcript is stored it shows the
|
|
// honest "not available" state and no form (ADR-027: never a fetch). The prior
|
|
// turns ride as hidden hq/ha fields so the ephemeral conversation survives the
|
|
// round-trip without any persisted state.
|
|
templ chatPanel(v chatView) {
|
|
<div id="chat-panel" class="chat-panel">
|
|
if !v.Available {
|
|
<p class="chat-unavailable muted">Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.</p>
|
|
} else {
|
|
if len(v.History) > 0 {
|
|
<div class="chat-log">
|
|
for _, t := range v.History {
|
|
<div class="chat-turn chat-q"><p>{ t.Question }</p></div>
|
|
<div class="chat-turn chat-a"><p class="body">{ t.Answer }</p></div>
|
|
}
|
|
</div>
|
|
}
|
|
if v.Truncated {
|
|
<p class="chat-note muted">Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.</p>
|
|
}
|
|
if v.Error != "" {
|
|
<p class="chat-error" role="alert">{ v.Error }</p>
|
|
}
|
|
<form
|
|
class="chat-form"
|
|
method="post"
|
|
action={ chatURL(v.VideoID) }
|
|
hx-post={ string(chatURL(v.VideoID)) }
|
|
hx-target="#chat-panel"
|
|
hx-swap="outerHTML"
|
|
>
|
|
for _, t := range v.History {
|
|
<input type="hidden" name="hq" value={ t.Question }/>
|
|
<input type="hidden" name="ha" value={ t.Answer }/>
|
|
}
|
|
<label class="chat-model">
|
|
Model
|
|
<select name="model">
|
|
for _, m := range v.Models {
|
|
<option value={ m } selected?={ m == v.Selected }>{ m }</option>
|
|
}
|
|
</select>
|
|
<span class="chat-model-hint muted">Switch models to compare answers on the same transcript.</span>
|
|
</label>
|
|
<textarea name="question" rows="3" placeholder="Ask a question about this video…" required aria-label="Your question"></textarea>
|
|
<button type="submit" class="btn">Ask</button>
|
|
<span class="htmx-indicator chat-thinking">thinking…</span>
|
|
</form>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
|
|
// subject with no tapir user picks a display name 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>
|
|
<button type="submit" class="btn">Register</button>
|
|
</form>
|
|
</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 new videos from about the last week as they are
|
|
discovered. Older videos stay browsable — summarize them on demand.
|
|
Manual lets you pick which videos to summarize — every new video appears
|
|
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"
|
|
>
|
|
// watched ↔ skipped are mutually exclusive (the store clears one when the
|
|
// other is set), so they read as a single segmented choice. "saved" is an
|
|
// independent toggle and sits apart (UX review C5).
|
|
<span class="segmented" role="group" aria-label="Watched or skipped">
|
|
@actionButton("watched", active["watched"])
|
|
@actionButton("skipped", active["skipped"])
|
|
</span>
|
|
@actionButton("saved", active["saved"])
|
|
</form>
|
|
}
|
|
|
|
// actionButton is one toggle button in the action group: a submit carrying its
|
|
// verb, marked active (accent fill + ✓ prefix + aria-pressed) when currently set.
|
|
templ actionButton(verb string, isActive bool) {
|
|
<button
|
|
type="submit"
|
|
name="action"
|
|
value={ verb }
|
|
class={ "action", templ.KV("active", isActive) }
|
|
aria-pressed={ ariaPressed(isActive) }
|
|
>
|
|
if isActive {
|
|
{ "✓ " + actionLabel(verb) }
|
|
} else {
|
|
{ actionLabel(verb) }
|
|
}
|
|
</button>
|
|
}
|