Logout was only reachable from /welcome. Users who are logged in had no way to sign out from any app page (list, detail, account). Added to the shared nav alongside Account.
432 lines
14 KiB
Templ
432 lines
14 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">New to Tapir? Just sign in — you'll complete a quick setup right after. Already have an account? You'll go straight through.</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.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>
|
|
}
|
|
}
|
|
|
|
// 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>
|
|
}
|