A deeper-dive chat entered from the summary view: ask questions about a video against its already-stored transcript (ADR-021), no caption fetch, ever. Safety by construction — the load-bearing property. The chat handlers reach the chat.Service only after reading the SHARED stored transcript via Store.GetTranscript (a pure DB read); the service holds no VideoSource. So an enabled chat cannot trigger a caption fetch, touch the rate gate, or reach YouTube. A video with no stored transcript gets an honest "not available" — no fetch, no model call. The key web test wires the summarize/fetch collaborators as tripwires that fail the test if chat ever routes into them, and asserts the model answered from the stored text. Model defaults to the summary's own model and is switchable among the ADR-022 chain (phi4-mini → gemma4-26b → mistral-small); switching re-runs against the same transcript — deliberate model-comparison instrumentation. The cloud model is absent from the switcher when TAPIR_CLOUD_FALLBACK_MODEL="" (the local-first / NDA lever), honoured the same way the summarizer honours it. Reuses the existing LiteLLM gateway client (a chat is a different call, not a new integration) and the TAPIR_MAX_TRANSCRIPT_CHARS truncation, surfacing an honest bounded-context note when a long transcript is cut. Ephemeral v1: the multi-turn conversation rides in hidden request fields; no table, no migration, nothing persisted. Entry is RLS-scoped through GetSummaryByVideo, so chat is reachable only from the user's own summary view. Show-source verification and on-demand fetch are deferred (ADR-027). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
729 lines
26 KiB
Templ
729 lines
26 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">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 {
|
|
<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 {
|
|
// 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>
|
|
}
|
|
|
|
// 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>
|
|
}
|
|
|
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
|
// and the action button group. chatEnabled adds the "dig deeper" affordance
|
|
// (ADR-027) — a quiet link into the per-video chat over the stored transcript.
|
|
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>
|
|
<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>
|
|
}
|
|
if chatEnabled {
|
|
// Where the "I want more" reaction goes (ADR-027). A quiet link, not a
|
|
// loud CTA — it deepens value for a reader already here, never nudges.
|
|
<p class="dig-deeper"><a class="btn-secondary" href={ chatURL(r.VideoID) }>Dig deeper — ask about this video →</a></p>
|
|
}
|
|
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
|
// Lead with the attention-saving payload: Takeaways ("is this worth my
|
|
// time?") first, then Highlights, then the full Summary last (UX review
|
|
// A8). Takeaways/Highlights are conditional, so a video without them falls
|
|
// through to the Summary leading naturally.
|
|
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>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// ChatPage is the per-video deeper-dive chat (ADR-027): a question/answer surface
|
|
// over the video's ALREADY-STORED transcript. The back link returns to the
|
|
// summary it was entered from. All the interaction lives in chatPanel so the HTMX
|
|
// answer-swap and the no-JS full-page render share one component.
|
|
templ ChatPage(v chatView) {
|
|
@Layout("Tapir — Ask — " + v.Title) {
|
|
<article class="detail chat">
|
|
<p class="back"><a href={ videoURL(v.VideoID) }>← { v.Title }</a></p>
|
|
<h1>Ask about this video</h1>
|
|
<p class="chat-scope muted">Answers come only from this video's stored transcript — Tapir never fetches anything new here.</p>
|
|
@chatPanel(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>
|
|
}
|