feat(web): reusable flash/notification banner (PRG)

Add a one-shot flash component used across the app — connect success,
disconnect, account delete, and registration — instead of per-page ad-hoc
markup. setFlash queues a short-lived HttpOnly+SameSite cookie carrying an
opaque code; takeFlash consumes it on the next full-page render (not on
HTMX fragments). flashBanner maps the code to a styled, role=status banner;
the message text lives server-side in flashMessages so the cookie never
carries free text and a forged/unknown code renders nothing.

Wire it into the list page (the PRG landing spot for connect/registration)
and set it on registration and connect-callback success. Styled with the
existing design-system tokens; header gains an Account nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:52:03 +02:00
co-authored by Claude Opus 4.8
parent 17d5e8c393
commit 2fe4833434
8 changed files with 448 additions and 214 deletions
+1
View File
@@ -134,6 +134,7 @@ func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request)
return
}
setFlash(w, flashConnected)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+54
View File
@@ -0,0 +1,54 @@
package web
import "net/http"
// flashCookie carries a one-shot notification code between a POST→redirect and
// the next rendered page (PRG pattern). The value is a non-sensitive code (not
// user data), so it is not signed; HttpOnly + SameSite=Lax + a short MaxAge bound
// it. The flashBanner component maps the code to a styled message.
const flashCookie = "tapir_flash"
// Flash codes. Kept small and stable — the message + severity live in
// flashMessages (view.go), not here, so the cookie never carries free text.
const (
flashConnected = "connected"
flashConnectFailed = "connect_failed"
flashDisconnected = "disconnected"
flashDeleted = "deleted"
flashRegistered = "registered"
)
// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to
// survive the redirect, short enough that a stale banner never reappears.
const flashMaxAge = 60
// setFlash queues a one-shot notification surfaced by the next full page render.
func setFlash(w http.ResponseWriter, code string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: code,
Path: "/",
MaxAge: flashMaxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// takeFlash returns the pending flash code (if any) and clears the cookie so the
// banner shows exactly once. Call it only on full-page renders, not HTMX
// fragments, so a fragment swap never consumes a flash meant for the next page.
func takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(flashCookie)
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return c.Value
}
+54
View File
@@ -0,0 +1,54 @@
package web
import (
"context"
"strings"
"testing"
)
// TestFlashBannerRendersEachKind proves the reusable notification component
// renders a banner with the right message and severity class for every flash
// code, and renders nothing for an empty or unknown (e.g. forged) code.
func TestFlashBannerRendersEachKind(t *testing.T) {
cases := []struct {
code string
wantText string
wantKind string
}{
{flashConnected, "YouTube account connected", "flash-success"},
{flashConnectFailed, "Could not connect", "flash-error"},
{flashDisconnected, "Account disconnected", "flash-success"},
{flashDeleted, "account and all its data were deleted", "flash-success"},
{flashRegistered, "Welcome to Tapir", "flash-success"},
}
for _, tc := range cases {
t.Run(tc.code, func(t *testing.T) {
var sb strings.Builder
if err := flashBanner(tc.code).Render(context.Background(), &sb); err != nil {
t.Fatalf("render: %v", err)
}
got := sb.String()
if !strings.Contains(got, tc.wantText) {
t.Errorf("banner %q = %q, want it to contain %q", tc.code, got, tc.wantText)
}
if !strings.Contains(got, tc.wantKind) {
t.Errorf("banner %q = %q, want severity class %q", tc.code, got, tc.wantKind)
}
if !strings.Contains(got, `role="status"`) {
t.Errorf("banner %q must carry role=status for assistive tech, got %q", tc.code, got)
}
})
}
}
func TestFlashBannerRendersNothingForUnknownCode(t *testing.T) {
for _, code := range []string{"", "bogus", "<script>"} {
var sb strings.Builder
if err := flashBanner(code).Render(context.Background(), &sb); err != nil {
t.Fatalf("render: %v", err)
}
if got := strings.TrimSpace(sb.String()); got != "" {
t.Errorf("flashBanner(%q) = %q, want empty (no banner)", code, got)
}
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
a.render(w, r, summaryList(rows))
return
}
a.render(w, r, ListPage(rows, f))
a.render(w, r, ListPage(rows, f, takeFlash(w, r)))
}
// handleDetail renders one summary in full (highlights, takeaways, action group).
+1
View File
@@ -119,6 +119,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
a.serverError(w, r, "register user", err)
return
}
setFlash(w, flashRegistered)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+33 -1
View File
@@ -176,6 +176,29 @@ func externalURL(u string) templ.SafeURL {
return templ.URL(u)
}
// flashView is the rendered form of a flash code: a severity (drives the banner
// colour) and the human message. Keeping the text here — not in the cookie —
// means the cookie only ever carries an opaque, validated code.
type flashView struct {
Kind string // "success" | "error"
Message string
}
// flashMessages maps each flash code to its banner. An unknown code renders no
// banner (flashFor returns ok=false), so a forged cookie value is inert.
var flashMessages = map[string]flashView{
flashConnected: {"success", "YouTube account connected."},
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
flashDisconnected: {"success", "Account disconnected."},
flashDeleted: {"success", "Your account and all its data were deleted."},
flashRegistered: {"success", "Welcome to Tapir — your account is ready."},
}
func flashFor(code string) (flashView, bool) {
f, ok := flashMessages[code]
return f, ok
}
// Filter holds the list-view query parameters. Empty fields mean "no constraint".
// Dates are kept as the raw YYYY-MM-DD strings so the form re-renders the user's
// input verbatim; parsing happens in matchFilter.
@@ -261,8 +284,9 @@ body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: va
a { color: var(--accent); text-decoration: none; }
a:hover, a:focus-visible { text-decoration: underline; }
a:visited { color: var(--accent); }
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); }
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.muted { color: var(--muted); }
@@ -291,6 +315,14 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
/* flash / notification banner */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
@media (prefers-color-scheme: dark) {
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
}
/* htmx loading feedback */
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
+19 -3
View File
@@ -20,7 +20,10 @@ templ Layout(title string) {
@templ.Raw(styleTag)
</head>
<body>
<header><a href="/" class="brand">Tapir</a></header>
<header>
<a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a></nav>
</header>
<main>
{ children... }
</main>
@@ -28,10 +31,23 @@ templ Layout(title string) {
</html>
}
// 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.
templ ListPage(rows []store.SummaryRow, f Filter) {
// #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) {
@Layout("Tapir — Summaries") {
@flashBanner(flash)
@filterForm(f)
<div id="summary-list">
@summaryList(rows)
File diff suppressed because it is too large Load Diff