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:
@@ -134,6 +134,7 @@ func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
setFlash(w, flashConnected)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
+424
-348
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user