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
+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; }