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 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent a6949dc1fe
commit 201dfe55a0

@ -343,6 +343,51 @@ pub async fn admin_unlock(admin_code: &str) -> Result<bool, ApiError> {
}
}
/// 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<String>,
/// 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<String>,
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.

@ -6,3 +6,4 @@ pub mod auth_modal;
pub mod error;
pub mod pagination;
pub mod quote_card;
pub mod report_modal;

@ -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 `<script>` tag in `index.html`. After
//! solving, Turnstile writes the token into a hidden `<input name="cf-turnstile-response">`.
//! We read that value through the DOM on submit.
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
/// Cloudflare Turnstile site key.
///
/// Replace with the real site key before going to production.
/// `"0x4AAAAAAAAAAAAAAAAAAAAAA"` is the Turnstile always-passes test key.
const TURNSTILE_SITE_KEY: &str = "0x4AAAAAAAAAAAAAAAAAAAAAA";
/// Maximum allowed length for the optional report reason.
const MAX_REASON_LEN: usize = 256;
/// Props for the [`ReportModal`] component.
#[derive(Properties, PartialEq)]
pub struct ReportModalProps {
/// Callback invoked with `(reason, captcha_token)` when the user submits.
pub on_submit: Callback<(Option<String>, String)>,
/// Callback invoked when the user clicks Cancel.
pub on_cancel: Callback<()>,
}
/// Modal dialog for reporting a quote.
///
/// Renders a reason textarea, a Cloudflare Turnstile CAPTCHA widget, and
/// Submit / Cancel buttons. The Submit button is disabled until the Turnstile
/// widget has been solved (i.e., the hidden input carries a non-empty token).
///
/// The Turnstile widget is rendered by injecting a `<div class="cf-turnstile">`
/// with the appropriate `data-sitekey` attribute. Turnstile's global JS (loaded
/// in `index.html`) picks up that div automatically.
///
/// # Example
///
/// ```ignore
/// html! {
/// <ReportModal
/// on_submit={Callback::from(|(reason, token)| { /* send to API */ })}
/// on_cancel={Callback::from(|_| {})}
/// />
/// }
/// ```
#[function_component(ReportModal)]
pub fn report_modal(props: &ReportModalProps) -> Html {
let reason = use_state(String::new);
// Whether the Turnstile CAPTCHA has been solved (token is non-empty).
let captcha_solved = use_state(|| false);
// --- Handle reason textarea input ---
let on_reason_input = {
let reason = reason.clone();
Callback::from(move |e: InputEvent| {
let textarea: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
let mut val = textarea.value();
if val.len() > MAX_REASON_LEN {
val.truncate(MAX_REASON_LEN);
}
reason.set(val);
})
};
// --- Poll the hidden Turnstile input for a token on the "input" event ---
// Turnstile fires a synthetic "input" event on the hidden field when solved.
// We wire this up via use_effect so we can attach the listener to the DOM.
{
let captcha_solved = captcha_solved.clone();
use_effect(move || {
let window = web_sys::window().expect("no global window");
let document = window.document().expect("no document");
// Closure to check the hidden input value.
let captcha_solved_clone = captcha_solved.clone();
let check_fn = wasm_bindgen::closure::Closure::<dyn Fn()>::new(move || {
let document = web_sys::window()
.and_then(|w| w.document())
.expect("document");
if let Some(input_el) = document
.query_selector("input[name='cf-turnstile-response']")
.ok()
.flatten()
{
let input: HtmlInputElement = input_el.unchecked_into();
captcha_solved_clone.set(!input.value().is_empty());
}
});
// Attach the listener to the document so it catches the event
// regardless of where Turnstile fires it.
let _ = document
.add_event_listener_with_callback("input", check_fn.as_ref().unchecked_ref());
// Also run once immediately in case the widget already solved
// (e.g., page revisit).
check_fn
.as_ref()
.unchecked_ref::<js_sys::Function>()
.call0(&wasm_bindgen::JsValue::NULL)
.ok();
// Keep the closure alive until the component unmounts.
check_fn.forget();
|| ()
});
}
// --- Form submit handler ---
let on_submit = {
let reason = reason.clone();
let on_submit_cb = props.on_submit.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
// Read the Turnstile token from the hidden input.
let token = web_sys::window()
.and_then(|w| w.document())
.and_then(|doc| {
doc.query_selector("input[name='cf-turnstile-response']")
.ok()
.flatten()
})
.map(|el| el.unchecked_into::<HtmlInputElement>().value())
.unwrap_or_default();
if token.is_empty() {
return; // Should not happen — button is disabled until solved.
}
let reason_val = {
let s = (*reason).clone();
if s.is_empty() {
None
} else {
Some(s)
}
};
on_submit_cb.emit((reason_val, token));
})
};
let reason_len = reason.len();
let submit_disabled = !*captcha_solved;
let on_cancel = props.on_cancel.clone();
html! {
<div class="report-modal__overlay">
<div class="report-modal">
<h2 class="report-modal__title">{ "Report Quote" }</h2>
<p class="report-modal__desc">
{ "Use this form to report a quote that is inaccurate, offensive, or otherwise problematic." }
</p>
<form onsubmit={on_submit}>
<div class="report-modal__field">
<label class="report-modal__label">
{ "Reason (optional)" }
</label>
<textarea
class="report-modal__textarea"
placeholder="Describe the issue…"
value={(*reason).clone()}
oninput={on_reason_input}
maxlength={(MAX_REASON_LEN as u32).to_string()}
/>
<div class="report-modal__counter">
{ format!("{}/{}", reason_len, MAX_REASON_LEN) }
</div>
</div>
// Turnstile renders itself into any div with class "cf-turnstile"
// and the correct data-sitekey attribute.
<div class="report-modal__captcha">
<div
class="cf-turnstile"
data-sitekey={TURNSTILE_SITE_KEY}
data-theme="light"
/>
</div>
<div class="report-modal__actions">
<button
type="submit"
class="btn btn--danger"
disabled={submit_disabled}
>
{ "Submit Report" }
</button>
<button
type="button"
class="btn"
onclick={Callback::from(move |_| on_cancel.emit(()))}
>
{ "Cancel" }
</button>
</div>
</form>
</div>
</div>
}
}

@ -1,9 +1,10 @@
//! Quote detail page — view, edit, and delete a single quote.
//! Quote detail page — view, edit, delete, and report a single quote.
use crate::api::{self, ApiError};
use crate::components::auth_modal::AuthModal;
use crate::components::error::ErrorDisplay;
use crate::components::quote_card::QuoteCard;
use crate::components::report_modal::ReportModal;
use crate::{storage, Route};
use quotesdb::{Quote, UpdateQuoteInput};
use wasm_bindgen_futures::spawn_local;
@ -22,6 +23,8 @@ enum Action {
EditForm,
/// Delete auth modal shown.
DeleteAuth,
/// Report modal shown.
Report,
}
/// Props for the [`QuotePage`] component.
@ -34,10 +37,11 @@ pub struct QuotePageProps {
/// Quote detail page.
///
/// Fetches a quote by ID, renders it with [`QuoteCard`], and provides
/// Edit and Delete actions each guarded by the [`AuthModal`] component.
/// Edit, Delete, and Report actions.
///
/// - Edit: shows auth modal → edit form → `POST /api/quotes/:id` → re-fetch
/// - Delete: shows auth modal → `DELETE /api/quotes/:id` → navigate to `/browse`
/// - Report: shows [`ReportModal`] → `POST /api/quotes/:id/report` → success message
/// - 403 errors clear the stored auth code and display an error message.
/// - 404 errors display a user-friendly "not found" message.
#[function_component(QuotePage)]
@ -48,6 +52,7 @@ pub fn quote_page(props: &QuotePageProps) -> Html {
let loading = use_state(|| true);
let action = use_state(|| Action::None);
let action_error: UseStateHandle<Option<String>> = use_state(|| None);
let report_success = use_state(|| false);
// Edit form state — pre-filled from fetched quote
let edit_text = use_state(String::new);
@ -237,9 +242,38 @@ pub fn quote_page(props: &QuotePageProps) -> Html {
let on_cancel = {
let action = action.clone();
let action_error = action_error.clone();
let report_success = report_success.clone();
Callback::from(move |_| {
action.set(Action::None);
action_error.set(None);
report_success.set(false);
})
};
// --- Report modal submitted ---
let on_report_submit = {
let id = id.clone();
let action = action.clone();
let action_error = action_error.clone();
let report_success = report_success.clone();
Callback::from(move |(reason, captcha_token): (Option<String>, String)| {
let id = id.clone();
let action = action.clone();
let action_error = action_error.clone();
let report_success = report_success.clone();
spawn_local(async move {
match api::report_quote(&id, reason, captcha_token).await {
Ok(()) => {
report_success.set(true);
action.set(Action::None);
action_error.set(None);
}
Err(e) => {
action_error.set(Some(format!("Report failed: {}", e)));
action.set(Action::None);
}
}
});
})
};
@ -262,6 +296,12 @@ pub fn quote_page(props: &QuotePageProps) -> Html {
</div>
}
if *report_success {
<div class="page-quote__report-success">
{ "Thank you — your report has been submitted." }
</div>
}
if let Some(action_err) = (*action_error).clone() {
<ErrorDisplay message={action_err} />
}
@ -299,6 +339,21 @@ pub fn quote_page(props: &QuotePageProps) -> Html {
>
{ "Delete" }
</button>
<button
class="btn btn--muted"
onclick={{
let action = action.clone();
let action_error = action_error.clone();
let report_success = report_success.clone();
Callback::from(move |_| {
action_error.set(None);
report_success.set(false);
action.set(Action::Report);
})
}}
>
{ "Report" }
</button>
</div>
}
@ -414,6 +469,13 @@ pub fn quote_page(props: &QuotePageProps) -> Html {
prefill={storage::get_auth_code(&id)}
/>
}
if *action == Action::Report {
<ReportModal
on_submit={on_report_submit}
on_cancel={on_cancel.clone()}
/>
}
} else {
<p>{ "Quote not found." }</p>
}

@ -233,6 +233,17 @@ code {
color: #ffffff;
}
.btn--muted {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text-muted);
}
.btn--muted:hover {
background: var(--color-bg);
color: var(--color-text);
}
/* ── QuoteCard ─────────────────────────────────────────────── */
.quote-card {
background: var(--color-surface);
@ -350,6 +361,86 @@ code {
color: var(--color-text-muted);
}
/* ── Report Modal ──────────────────────────────────────────── */
.report-modal__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.report-modal {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
padding: 2rem;
width: 100%;
max-width: 480px;
}
.report-modal__title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.report-modal__desc {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 1.25rem;
}
.report-modal__field {
margin-bottom: 1.25rem;
}
.report-modal__label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.4rem;
color: var(--color-text);
}
.report-modal__textarea {
width: 100%;
min-height: 5rem;
}
.report-modal__counter {
text-align: right;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.report-modal__captcha {
margin-bottom: 1.25rem;
display: flex;
justify-content: center;
}
.report-modal__actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* ── Quote Detail — report success notice ──────────────────── */
.page-quote__report-success {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: var(--radius);
padding: 0.875rem 1rem;
margin-bottom: 1rem;
color: var(--color-success);
font-size: 0.95rem;
}
/* ── Auth Modal ────────────────────────────────────────────── */
.auth-modal__overlay {
position: fixed;

Loading…
Cancel
Save