From b6f03fd967f28de1b3f68cf7afe78bbc957724a2 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 8 Mar 2026 20:28:40 -0700 Subject: [PATCH] feat(quotesdb): add report button with modal on quote page Adds a Report button to /quotes/:id that opens a modal containing: - Optional reason textarea (max 256 chars) with live character counter - Cloudflare Turnstile CAPTCHA widget (always-passes test sitekey as const) - Submit button disabled until CAPTCHA token is non-empty - Cancel button On submit POSTs { reason?, captcha_token } to POST /api/quotes/:id/report. Shows a success banner on 201 or an error via ErrorDisplay on failure. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/src/bin/ui/api.rs | 45 ++++ quotesdb/src/bin/ui/components/mod.rs | 1 + .../src/bin/ui/components/report_modal.rs | 212 ++++++++++++++++++ quotesdb/src/bin/ui/pages/quote.rs | 66 +++++- quotesdb/src/bin/ui/style.css | 91 ++++++++ 5 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 quotesdb/src/bin/ui/components/report_modal.rs diff --git a/quotesdb/src/bin/ui/api.rs b/quotesdb/src/bin/ui/api.rs index 30bd49f..01fb23b 100644 --- a/quotesdb/src/bin/ui/api.rs +++ b/quotesdb/src/bin/ui/api.rs @@ -343,6 +343,51 @@ pub async fn admin_unlock(admin_code: &str) -> Result { } } +/// Request body for `POST /api/quotes/:id/report`. +#[derive(Serialize)] +struct ReportBody { + /// Optional human-readable reason for the report (max 256 characters). + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + /// Cloudflare Turnstile CAPTCHA token obtained from the widget. + captcha_token: String, +} + +/// Submit a report for a quote. +/// +/// Posts `{ reason?, captcha_token }` to `POST /api/quotes/:id/report`. +/// Returns `Ok(())` on HTTP 201. Returns [`ApiError::Server`] for other errors. +/// +/// # Arguments +/// - `id` — the quote's NanoID. +/// - `reason` — optional user-provided reason text (max 256 characters). +/// - `captcha_token` — Cloudflare Turnstile token from the solved CAPTCHA widget. +pub async fn report_quote( + id: &str, + reason: Option, + captcha_token: String, +) -> Result<(), ApiError> { + let body = ReportBody { + reason, + captcha_token, + }; + let resp = gloo::net::http::Request::post(&format!("/api/quotes/{id}/report")) + .json(&body) + .map_err(|e| ApiError::Network(e.to_string()))? + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + if resp.status() == 201 { + Ok(()) + } else { + let msg = resp.text().await.unwrap_or_default(); + Err(ApiError::Server { + status: resp.status(), + message: msg, + }) + } +} + /// Internal helper: GET a URL and deserialise the response body as JSON. /// /// Returns `ApiError` on non-2xx status or deserialisation failure. diff --git a/quotesdb/src/bin/ui/components/mod.rs b/quotesdb/src/bin/ui/components/mod.rs index 05f88eb..412ff88 100644 --- a/quotesdb/src/bin/ui/components/mod.rs +++ b/quotesdb/src/bin/ui/components/mod.rs @@ -6,3 +6,4 @@ pub mod auth_modal; pub mod error; pub mod pagination; pub mod quote_card; +pub mod report_modal; diff --git a/quotesdb/src/bin/ui/components/report_modal.rs b/quotesdb/src/bin/ui/components/report_modal.rs new file mode 100644 index 0000000..de24e87 --- /dev/null +++ b/quotesdb/src/bin/ui/components/report_modal.rs @@ -0,0 +1,212 @@ +//! ReportModal component — lets users report a quote with an optional reason +//! and a Cloudflare Turnstile CAPTCHA. +//! +//! The modal renders: +//! - A textarea for an optional reason (max 256 characters, with a live counter). +//! - A Cloudflare Turnstile widget that sets a hidden input once solved. +//! - Submit (disabled until the CAPTCHA is solved) and Cancel buttons. +//! +//! Turnstile is loaded globally via the `