feat(web): summarization-mode UI — all-videos list, Summarize queue, mode toggle
The list now shows ALL videos (ListVideos), not just summaries. Summarized cards
are unchanged; discovered-but-unsummarized videos render with a muted "pending"
treatment and either a "Summarize" button or a "Queued" chip.
- POST /v/{videoId}/summarize queues a video (RequestSummarize) and returns the
refreshed card — it does NOT run the engine inline; `tapir run` is the single
summarization driver, which picks up the flag on its next pass.
- Account page gains an Automatic/Manual toggle (POST /account/summarize-mode →
SetAutoSummarize), shown as the current mode with a one-click switch.
- Both new POSTs degrade without JS (redirect back); HTMX swaps the fragment.
VideoCard and summarizeModeControl are extracted templ fragments reused as the
HTMX swap targets. views_templ.go regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,12 @@ func (a *App) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if u, ok := a.Auth.CurrentUser(r); ok {
|
if u, ok := a.Auth.CurrentUser(r); ok {
|
||||||
email = u.Email
|
email = u.Email
|
||||||
}
|
}
|
||||||
a.render(w, r, AccountPage(name, email, conns, takeFlash(w, r)))
|
auto, err := a.Store.GetAutoSummarize(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "summarize mode", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, AccountPage(name, email, conns, auto, takeFlash(w, r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDisconnect removes a provider connection: it deletes the OAuth token from
|
// handleDisconnect removes a provider connection: it deletes the OAuth token from
|
||||||
|
|||||||
@@ -16,12 +16,19 @@ import (
|
|||||||
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
||||||
// fake without a database.
|
// fake without a database.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
ListSummaries(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
||||||
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
|
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
||||||
SetAction(ctx context.Context, userID, videoID, action string) error
|
SetAction(ctx context.Context, userID, videoID, action string) error
|
||||||
ClearAction(ctx context.Context, userID, videoID, action string) error
|
ClearAction(ctx context.Context, userID, videoID, action string) error
|
||||||
|
|
||||||
|
// Summarization mode: the per-user auto/manual toggle and the per-video
|
||||||
|
// manual queue (the "Summarize" button). The runner consumes the queue.
|
||||||
|
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
|
||||||
|
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
|
||||||
|
RequestSummarize(ctx context.Context, userID, videoID string) error
|
||||||
|
|
||||||
// Account management (the /account page, disconnect, delete-account).
|
// Account management (the /account page, disconnect, delete-account).
|
||||||
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
||||||
DeleteConnection(ctx context.Context, userID, provider string) error
|
DeleteConnection(ctx context.Context, userID, provider string) error
|
||||||
@@ -76,6 +83,7 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /{$}", a.handleList)
|
app.HandleFunc("GET /{$}", a.handleList)
|
||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||||
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||||
app.HandleFunc("POST /register", a.handleRegister)
|
app.HandleFunc("POST /register", a.handleRegister)
|
||||||
|
|
||||||
@@ -84,6 +92,7 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /account", a.handleAccount)
|
app.HandleFunc("GET /account", a.handleAccount)
|
||||||
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
||||||
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
||||||
|
app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)
|
||||||
|
|
||||||
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
||||||
// CurrentUserID is set and the connection binds to the authenticated user.
|
// CurrentUserID is set and the connection binds to the authenticated user.
|
||||||
@@ -121,9 +130,9 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
To: q.Get("to"),
|
To: q.Get("to"),
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := a.Store.ListSummaries(r.Context(), userID, 0)
|
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "list summaries", err)
|
a.serverError(w, r, "list videos", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows = f.apply(rows)
|
rows = f.apply(rows)
|
||||||
@@ -199,6 +208,59 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRequestSummarize queues a video for manual summarization. It does NOT run
|
||||||
|
// the engine inline — it only flips summarize_requested; the next `tapir run`
|
||||||
|
// picks it up (the single summarization driver). For HTMX it returns the refreshed
|
||||||
|
// card (now showing "Queued"); without JS it redirects back to the list.
|
||||||
|
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
err := a.Store.RequestSummarize(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "request summarize", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||||
|
// submits the desired new value (enabled=true|false). For HTMX it returns the
|
||||||
|
// refreshed mode control; without JS it redirects back to the account page.
|
||||||
|
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled := r.FormValue("enabled") == "true"
|
||||||
|
if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
|
||||||
|
a.serverError(w, r, "set summarize mode", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, summarizeModeControl(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
// currentUserID returns the tapir user_id the registration gate resolved for this
|
// currentUserID returns the tapir user_id the registration gate resolved for this
|
||||||
// request. Behind the gate it is always present; a miss means a handler was
|
// request. Behind the gate it is always present; a miss means a handler was
|
||||||
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
||||||
|
|||||||
@@ -185,8 +185,10 @@ func TestListRendersRowsAndActionState(t *testing.T) {
|
|||||||
func TestListHTMXReturnsFragment(t *testing.T) {
|
func TestListHTMXReturnsFragment(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
app := newApp(t)
|
app := newApp(t)
|
||||||
resetDB(t, rawPool(t))
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.Header.Set("HX-Request", "true")
|
req.Header.Set("HX-Request", "true")
|
||||||
@@ -290,6 +292,103 @@ func TestActionRejectsUnknownVerb(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
// A discovered-but-unsummarized video (no summary delivered).
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
require.Contains(t, html, "Pending Title", "unsummarized videos are listed too")
|
||||||
|
require.Contains(t, html, "Summarize", "a Summarize button is offered")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/summarize", "button posts to the queue endpoint")
|
||||||
|
require.Contains(t, html, "card-pending", "muted pending treatment")
|
||||||
|
require.NotContains(t, html, "Queued", "not queued yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Queued", "card now shows the queued state")
|
||||||
|
require.NotContains(t, html, ">Summarize<", "the Summarize button is gone once queued")
|
||||||
|
|
||||||
|
// The flag is persisted, so the next run picks it up.
|
||||||
|
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, row.SummarizeRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeNonHTMXRedirects(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, false)
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, row.SummarizeRequested, "queued on the no-JS path too")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeNotFound(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code, "queuing an unknown video is a 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeModeToggle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
// Account page defaults to manual.
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Manual", "default mode shown")
|
||||||
|
require.Contains(t, html, "Switch to automatic")
|
||||||
|
|
||||||
|
// Toggle to automatic via HTMX returns the refreshed control.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode",
|
||||||
|
strings.NewReader("enabled=true"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec = do(t, app, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html = body(t, rec)
|
||||||
|
require.Contains(t, html, "Automatic")
|
||||||
|
require.Contains(t, html, "Switch to manual")
|
||||||
|
|
||||||
|
got, err := app.Store.GetAutoSummarize(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got, "mode persisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func postSummarize(t *testing.T, app *web.App, videoID string, htmx bool) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/summarize", nil)
|
||||||
|
if htmx {
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
}
|
||||||
|
return do(t, app, req)
|
||||||
|
}
|
||||||
|
|
||||||
// deliver stores a summary through the App's store under test.
|
// deliver stores a summary through the App's store under test.
|
||||||
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
|
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
|
||||||
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
|
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
|
||||||
|
|||||||
@@ -171,6 +171,36 @@ func actionURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/action")
|
return templ.SafeURL("/v/" + videoID + "/action")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summarizeURL builds the manual-queue POST path for a video id.
|
||||||
|
func summarizeURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/summarize")
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeModeLabel names the current mode for display.
|
||||||
|
func summarizeModeLabel(auto bool) string {
|
||||||
|
if auto {
|
||||||
|
return "Automatic"
|
||||||
|
}
|
||||||
|
return "Manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeModeToggleLabel is the caption on the toggle button — it names the mode
|
||||||
|
// the click switches TO (the opposite of the current one).
|
||||||
|
func summarizeModeToggleLabel(auto bool) string {
|
||||||
|
if auto {
|
||||||
|
return "Switch to manual"
|
||||||
|
}
|
||||||
|
return "Switch to automatic"
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolStr renders a bool as the "enabled" form value the toggle submits.
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
// externalURL passes a stored source URL through templ's URL sanitiser.
|
// externalURL passes a stored source URL through templ's URL sanitiser.
|
||||||
func externalURL(u string) templ.SafeURL {
|
func externalURL(u string) templ.SafeURL {
|
||||||
return templ.URL(u)
|
return templ.URL(u)
|
||||||
@@ -347,6 +377,15 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.card-state { color: var(--muted); font-size: .8rem; }
|
.card-state { color: var(--muted); font-size: .8rem; }
|
||||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* pending (discovered-but-unsummarized) card: muted until summarized */
|
||||||
|
.card-pending { border-style: dashed; }
|
||||||
|
.card-pending .card-title { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
|
/* summarization mode toggle on the account page */
|
||||||
|
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
|
||||||
|
.summarize-mode p { margin: 0; }
|
||||||
|
.summarize-mode form { margin: 0; }
|
||||||
|
|
||||||
/* empty state */
|
/* empty state */
|
||||||
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
||||||
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
||||||
|
|||||||
+85
-27
@@ -73,44 +73,73 @@ templ filterForm(f Filter) {
|
|||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
// summaryList is the swappable list fragment: one card per summary (title link,
|
// summaryList is the swappable list fragment: one card per video (summarized or
|
||||||
// channel · date meta, provider chip, fallback badge, action state). Cards
|
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
|
||||||
// reflow to a single column on mobile; an empty list shows a friendly first-run
|
// first-run state instead of a blank table.
|
||||||
// state instead of a blank table.
|
|
||||||
templ summaryList(rows []store.SummaryRow) {
|
templ summaryList(rows []store.SummaryRow) {
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No summaries yet</strong>
|
<strong>No videos yet</strong>
|
||||||
<span>Summaries appear here as your subscriptions are processed — run <code>tapir run</code> to fetch and summarize new videos.</span>
|
<span>Videos appear here as your subscriptions are processed — run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<ul class="cards">
|
<ul class="cards">
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
<li class="card">
|
@VideoCard(r)
|
||||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
|
||||||
if cardMeta(r) != "" {
|
|
||||||
<div class="card-meta">{ cardMeta(r) }</div>
|
|
||||||
}
|
|
||||||
if p := previewText(r.Summary, 160); p != "" {
|
|
||||||
<div class="card-preview">{ p }</div>
|
|
||||||
}
|
|
||||||
<div class="card-foot">
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||||
// and the action button group.
|
// and the action button group.
|
||||||
templ DetailPage(r store.SummaryRow) {
|
templ DetailPage(r store.SummaryRow) {
|
||||||
@@ -202,7 +231,7 @@ templ RegisterPage(email, errMsg string) {
|
|||||||
// signed-in email, the user's connected video accounts (each with a Disconnect
|
// 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
|
// control), a Connect-YouTube link when none is connected, and the delete-account
|
||||||
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
||||||
templ AccountPage(displayName, email string, conns []store.Connection, flash string) {
|
templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) {
|
||||||
@Layout("Tapir — Account") {
|
@Layout("Tapir — Account") {
|
||||||
@flashBanner(flash)
|
@flashBanner(flash)
|
||||||
<article class="account">
|
<article class="account">
|
||||||
@@ -215,6 +244,15 @@ templ AccountPage(displayName, email string, conns []store.Connection, flash str
|
|||||||
<dd>{ email }</dd>
|
<dd>{ email }</dd>
|
||||||
}
|
}
|
||||||
</dl>
|
</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>
|
<section>
|
||||||
<h2>Connected accounts</h2>
|
<h2>Connected accounts</h2>
|
||||||
if len(conns) == 0 {
|
if len(conns) == 0 {
|
||||||
@@ -262,6 +300,26 @@ templ AccountPage(displayName, email string, conns []store.Connection, flash str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
||||||
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
// 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
|
// and without JS the form POSTs and the handler redirects back to the detail
|
||||||
|
|||||||
+524
-295
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user