//! Moderation tab component — admin view of reported quotes. //! //! Renders a paginated list of reported quotes and a report detail modal. //! All admin API calls include the `X-Admin-Code` header from the parent. //! //! # Usage //! //! ```ignore //! html! { //! //! } //! ``` use crate::api::{self, QuoteReports, ReportListResponse, ReportSummary}; use crate::components::pagination::Pagination; use wasm_bindgen_futures::spawn_local; use yew::prelude::*; /// Props for the [`ModerationTab`] component. #[derive(Properties, PartialEq)] pub struct ModerationTabProps { /// Verified admin code, passed as the `X-Admin-Code` header on all requests. pub admin_code: String, } /// Internal state for the report detail modal. #[derive(Clone, PartialEq)] enum ModalState { /// No modal shown. Closed, /// Fetching full report details for the given quote ID. Loading(String), /// Details fetched and modal is open. Open(QuoteReports), /// A hide action succeeded — show success notice inside the modal. HideSuccess(QuoteReports), /// An action error occurred inside the modal. Error(QuoteReports, String), } /// Admin moderation tab showing reported quotes and allowing moderation actions. /// /// Loads and paginates reported quotes via `GET /api/admin/reports?page=N`. /// Clicking a row opens a [`ModalState::Open`] detail modal with the full quote /// and all individual reports. Action buttons (Delete, Hide, Dismiss) call the /// corresponding admin endpoints and refresh the list on success. #[function_component(ModerationTab)] pub fn moderation_tab(props: &ModerationTabProps) -> Html { // --- List state --- let page = use_state(|| 1u32); let list: UseStateHandle> = use_state(|| None); let list_loading = use_state(|| false); let list_error: UseStateHandle> = use_state(|| None); // --- Modal state --- let modal = use_state(|| ModalState::Closed); // --- Action-in-progress guard --- let action_loading = use_state(|| false); // ── Fetch the report list whenever page changes ─────────────────────────── { let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let admin_code = props.admin_code.clone(); use_effect_with((*page, admin_code.clone()), move |(p, code)| { let p = *p; let code = code.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); list_loading.set(true); list_error.set(None); spawn_local(async move { match api::admin_list_reports(p, &code).await { Ok(data) => { list.set(Some(data)); list_error.set(None); } Err(e) => { list_error.set(Some(format!("Failed to load reports: {e}"))); } } list_loading.set(false); }); || () }); } // ── Open detail modal for a row ─────────────────────────────────────────── let on_row_click = { let modal = modal.clone(); let admin_code = props.admin_code.clone(); Callback::from(move |quote_id: String| { let modal = modal.clone(); let admin_code = admin_code.clone(); modal.set(ModalState::Loading(quote_id.clone())); spawn_local(async move { match api::admin_get_quote_reports("e_id, &admin_code).await { Ok(details) => modal.set(ModalState::Open(details)), Err(e) => { // Fall back to closed with a list-level error modal.set(ModalState::Closed); // We can't easily set list_error here; the component // will just close. A future refactor could thread // a separate modal_error state. let _ = e; } } }); }) }; // ── Close modal ─────────────────────────────────────────────────────────── let on_close = { let modal = modal.clone(); Callback::from(move |_: MouseEvent| { modal.set(ModalState::Closed); }) }; // ── Delete quote ────────────────────────────────────────────────────────── let on_delete = { let modal = modal.clone(); let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let action_loading = action_loading.clone(); let admin_code = props.admin_code.clone(); Callback::from(move |quote_id: String| { if *action_loading { return; } let modal = modal.clone(); let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let action_loading = action_loading.clone(); let admin_code = admin_code.clone(); action_loading.set(true); spawn_local(async move { match api::admin_delete_reported_quote("e_id, &admin_code).await { Ok(()) => { modal.set(ModalState::Closed); // Refresh the list on the current page. list_loading.set(true); list_error.set(None); match api::admin_list_reports(*page, &admin_code).await { Ok(data) => { list.set(Some(data)); } Err(e) => { list_error.set(Some(format!("Failed to refresh: {e}"))); } } list_loading.set(false); } Err(e) => { // Keep modal open, show error inside it. if let ModalState::Open(ref details) | ModalState::Error(ref details, _) = *modal { modal.set(ModalState::Error( details.clone(), format!("Delete failed: {e}"), )); } } } action_loading.set(false); }); }) }; // ── Hide quote ──────────────────────────────────────────────────────────── let on_hide = { let modal = modal.clone(); let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let action_loading = action_loading.clone(); let admin_code = props.admin_code.clone(); Callback::from(move |quote_id: String| { if *action_loading { return; } let modal = modal.clone(); let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let action_loading = action_loading.clone(); let admin_code = admin_code.clone(); action_loading.set(true); spawn_local(async move { match api::admin_hide_reported_quote("e_id, &admin_code).await { Ok(()) => { // Show success inside the modal (keep it open). if let ModalState::Open(ref details) | ModalState::Error(ref details, _) = *modal { modal.set(ModalState::HideSuccess(details.clone())); } // Refresh the list in the background. list_loading.set(true); list_error.set(None); match api::admin_list_reports(*page, &admin_code).await { Ok(data) => list.set(Some(data)), Err(e) => { list_error.set(Some(format!("Failed to refresh: {e}"))); } } list_loading.set(false); } Err(e) => { if let ModalState::Open(ref details) | ModalState::Error(ref details, _) = *modal { modal.set(ModalState::Error( details.clone(), format!("Hide failed: {e}"), )); } } } action_loading.set(false); }); }) }; // ── Dismiss reports ─────────────────────────────────────────────────────── let on_dismiss = { let modal = modal.clone(); let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let action_loading = action_loading.clone(); let admin_code = props.admin_code.clone(); Callback::from(move |quote_id: String| { if *action_loading { return; } let modal = modal.clone(); let page = page.clone(); let list = list.clone(); let list_loading = list_loading.clone(); let list_error = list_error.clone(); let action_loading = action_loading.clone(); let admin_code = admin_code.clone(); action_loading.set(true); spawn_local(async move { match api::admin_dismiss_reports("e_id, &admin_code).await { Ok(()) => { modal.set(ModalState::Closed); list_loading.set(true); list_error.set(None); match api::admin_list_reports(*page, &admin_code).await { Ok(data) => list.set(Some(data)), Err(e) => { list_error.set(Some(format!("Failed to refresh: {e}"))); } } list_loading.set(false); } Err(e) => { if let ModalState::Open(ref details) | ModalState::Error(ref details, _) = *modal { modal.set(ModalState::Error( details.clone(), format!("Dismiss failed: {e}"), )); } } } action_loading.set(false); }); }) }; // ── Render ──────────────────────────────────────────────────────────────── let modal_snapshot = (*modal).clone(); html! {

{ "Moderation" }

if *list_loading {

{ "Loading reports…" }

} else if let Some(err) = (*list_error).clone() {

{ err }

} else if let Some(ref data) = *list { if data.reports.is_empty() {

{ "No reported quotes." }

} else {
{ "Quote" } { "Author" } { "Reports" } { "Latest" }
{ for data.reports.iter().map(|r| { render_row(r, &on_row_click, &modal) }) }
if data.total_pages > 1 { } } } else {

{ "Loading…" }

} // ── Detail modal ────────────────────────────────────────────────── { match modal_snapshot { ModalState::Closed => html! {}, ModalState::Loading(_) => html! {

{ "Loading details…" }

}, ModalState::Open(ref details) | ModalState::HideSuccess(ref details) | ModalState::Error(ref details, _) => { let quote_id = details.quote.id.clone(); let is_hide_success = matches!(modal_snapshot, ModalState::HideSuccess(_)); let modal_err = match modal_snapshot { ModalState::Error(_, ref e) => Some(e.clone()), _ => None, }; render_modal( details, is_hide_success, modal_err, *action_loading, &on_close, { let on_delete = on_delete.clone(); let qid = quote_id.clone(); Callback::from(move |_: MouseEvent| on_delete.emit(qid.clone())) }, { let on_hide = on_hide.clone(); let qid = quote_id.clone(); Callback::from(move |_: MouseEvent| on_hide.emit(qid.clone())) }, { let on_dismiss = on_dismiss.clone(); let qid = quote_id.clone(); Callback::from(move |_: MouseEvent| on_dismiss.emit(qid.clone())) }, ) } } }
} } // ── Helper: render a single report summary row ──────────────────────────────── /// Renders a single row in the moderation list table. fn render_row( r: &ReportSummary, on_row_click: &Callback, modal: &UseStateHandle, ) -> Html { let loading = matches!(*(*modal), ModalState::Loading(ref id) if id == &r.quote_id); let qid = r.quote_id.clone(); let on_click = { let on_row_click = on_row_click.clone(); Callback::from(move |_: MouseEvent| on_row_click.emit(qid.clone())) }; // Format the date portion of the latest_report_at timestamp (first 10 chars). let date_display = r.latest_report_at.get(..10).unwrap_or(&r.latest_report_at); html! {
{ &r.text } { &r.author } { r.report_count } { date_display }
} } // ── Helper: render the detail modal ────────────────────────────────────────── /// Renders the report detail modal. #[allow(clippy::too_many_arguments)] fn render_modal( details: &QuoteReports, is_hide_success: bool, modal_err: Option, action_loading: bool, on_close: &Callback, on_delete: Callback, on_hide: Callback, on_dismiss: Callback, ) -> Html { let quote = &details.quote; let on_close = on_close.clone(); html! {

{ "Report Details" }

// ── Quote display ─────────────────────────────────────────────
{ "e.text }

{ "— " }{ "e.author } if let Some(ref src) = quote.source { { format!(", {src}") } } if let Some(ref d) = quote.date { { format!(" ({d})") } }

if !quote.tags.is_empty() {
{ for quote.tags.iter().map(|t| html! { { t } }) }
} if quote.hidden { { "Hidden" } }
// ── Individual reports ────────────────────────────────────────

{ format!("{} report(s)", details.reports.len()) }

if details.reports.is_empty() {

{ "No individual reports." }

} else { { for details.reports.iter().map(|rep| { let date = rep.created_at.get(..10).unwrap_or(&rep.created_at); html! {
{ date } { rep.reason.as_deref().unwrap_or("No reason given") }
} }) } }
// ── Feedback messages ───────────────────────────────────────── if is_hide_success {

{ "Quote hidden. It will no longer appear in listings." }

} if let Some(err) = modal_err {

{ err }

} // ── Action buttons ────────────────────────────────────────────
if !quote.hidden { }
} }