From eb4cc08a696880192c515dee3c4f9bc33aa77aed Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 09:16:05 -0700 Subject: [PATCH] Prevent duplicate post submissions --- internal/web/posts_test.go | 2 ++ internal/web/server_test.go | 10 ++++++ static/app.css | 35 +++++++++++++++++++++ static/app.js | 58 +++++++++++++++++++++++++++++++++++ templates/base.html | 5 +++ templates/partials/_post.html | 5 +-- templates/submit.html | 5 +-- 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 static/app.js diff --git a/internal/web/posts_test.go b/internal/web/posts_test.go index 8e798a1..1b63317 100644 --- a/internal/web/posts_test.go +++ b/internal/web/posts_test.go @@ -458,6 +458,8 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { "Shop response", "Edited", `action="/posts"`, + `data-submit-once`, + `data-submit-button`, `action="/posts/` + root.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`, `>The model number is 123A.`, diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 988ae49..5a9393d 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -168,6 +168,16 @@ func TestRegisterLoginAsk(t *testing.T) { if rec.Code != 200 { t.Fatalf("submit form %d", rec.Code) } + for _, want := range []string{ + `src="/static/app.js"`, + `id="submit-progress"`, + `data-submit-once`, + `data-submit-button`, + } { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("submit form missing %q: %s", want, rec.Body.String()) + } + } csrf := csrfFrom(rec.Body.String()) form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") req = httptest.NewRequest(http.MethodPost, "/submit", form) diff --git a/static/app.css b/static/app.css index 38898e0..c5fa9f7 100644 --- a/static/app.css +++ b/static/app.css @@ -41,6 +41,31 @@ body { background-size: 100% 100%, 48px 48px; } +.submit-progress { + position: fixed; + inset: 0 0 auto; + z-index: 100; + height: 3px; + overflow: hidden; + pointer-events: none; +} + +.submit-progress[hidden] { display: none; } + +.submit-progress-bar { + display: block; + width: 35%; + height: 100%; + background: var(--signal); + box-shadow: 0 0 12px var(--signal); + animation: submit-progress 900ms ease-in-out infinite; +} + +@keyframes submit-progress { + from { transform: translateX(-100%); } + to { transform: translateX(300%); } +} + img, svg { display: block; } a { @@ -253,6 +278,12 @@ a { .btn-primary:hover { filter: brightness(1.08); } +.btn:disabled { + cursor: wait; + opacity: 0.65; + filter: none; +} + .btn-ghost { background: transparent; color: var(--ink); @@ -763,6 +794,10 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn: @media (prefers-reduced-motion: reduce) { .btn-primary:hover { filter: none; } + .submit-progress-bar { + width: 100%; + animation: none; + } } .admin-users { margin-top: 20px; overflow-x: auto; } diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..1ec32a4 --- /dev/null +++ b/static/app.js @@ -0,0 +1,58 @@ +(() => { + const formSelector = "form[data-submit-once]"; + + function progressIndicator() { + return document.getElementById("submit-progress"); + } + + function resetForm(form) { + form.removeAttribute("aria-busy"); + delete form.dataset.submitting; + + const button = form.querySelector("[data-submit-button]"); + if (!button) { + return; + } + button.disabled = false; + button.removeAttribute("aria-disabled"); + if (button.dataset.idleLabel) { + button.textContent = button.dataset.idleLabel; + delete button.dataset.idleLabel; + } + } + + document.addEventListener("submit", (event) => { + const form = event.target.closest(formSelector); + if (!form) { + return; + } + if (form.dataset.submitting === "true") { + event.preventDefault(); + return; + } + + form.dataset.submitting = "true"; + form.setAttribute("aria-busy", "true"); + + const button = event.submitter || form.querySelector("[data-submit-button]"); + if (button) { + button.dataset.idleLabel = button.textContent; + button.textContent = form.dataset.submittingLabel || "Posting…"; + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + } + + const progress = progressIndicator(); + if (progress) { + progress.hidden = false; + } + }); + + window.addEventListener("pageshow", () => { + document.querySelectorAll(formSelector).forEach(resetForm); + const progress = progressIndicator(); + if (progress) { + progress.hidden = true; + } + }); +})(); diff --git a/templates/base.html b/templates/base.html index f7a32d0..591038f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,8 +11,13 @@ + +
diff --git a/templates/partials/_post.html b/templates/partials/_post.html index d01569f..95e1c26 100644 --- a/templates/partials/_post.html +++ b/templates/partials/_post.html @@ -3,13 +3,14 @@ {{if canReply .User .Root}}
Reply -
+
- +
diff --git a/templates/submit.html b/templates/submit.html index ce8668f..bfb2175 100644 --- a/templates/submit.html +++ b/templates/submit.html @@ -4,7 +4,8 @@

Ask a question

It lands on today’s hunt (Pacific time). People vote; the ranking resets at midnight PT.

{{if .Error}}{{end}} - + @@ -12,7 +13,7 @@ - +
{{template "footer" .}}