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>
quotesdb
parent
872ac9592c
commit
b6f03fd967
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue