You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

555 lines
24 KiB
Rust

//! 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! {
//! <ModerationTab admin_code="word-word-word-word" />
//! }
//! ```
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<Option<ReportListResponse>> = use_state(|| None);
let list_loading = use_state(|| false);
let list_error: UseStateHandle<Option<String>> = 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(&quote_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(&quote_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(&quote_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(&quote_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! {
<div class="moderation-tab">
<h2 class="admin-section__heading">{ "Moderation" }</h2>
if *list_loading {
<p class="moderation-tab__loading">{ "Loading reports…" }</p>
} else if let Some(err) = (*list_error).clone() {
<p class="admin-section__error">{ err }</p>
} else if let Some(ref data) = *list {
if data.reports.is_empty() {
<p class="moderation-tab__empty">{ "No reported quotes." }</p>
} else {
<div class="moderation-tab__list">
<div class="moderation-tab__header-row">
<span class="moderation-tab__col moderation-tab__col--text">
{ "Quote" }
</span>
<span class="moderation-tab__col moderation-tab__col--author">
{ "Author" }
</span>
<span class="moderation-tab__col moderation-tab__col--count">
{ "Reports" }
</span>
<span class="moderation-tab__col moderation-tab__col--date">
{ "Latest" }
</span>
</div>
{ for data.reports.iter().map(|r| {
render_row(r, &on_row_click, &modal)
}) }
</div>
if data.total_pages > 1 {
<Pagination
page={*page}
total_pages={data.total_pages}
on_page={{
let page = page.clone();
Callback::from(move |p| page.set(p))
}}
/>
}
}
} else {
<p class="moderation-tab__loading">{ "Loading…" }</p>
}
// ── Detail modal ──────────────────────────────────────────────────
{
match modal_snapshot {
ModalState::Closed => html! {},
ModalState::Loading(_) => html! {
<div class="moderation-modal__overlay">
<div class="moderation-modal">
<p class="moderation-tab__loading">{ "Loading details…" }</p>
</div>
</div>
},
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()))
},
)
}
}
}
</div>
}
}
// ── 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<String>,
modal: &UseStateHandle<ModalState>,
) -> 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! {
<div
class={classes!("moderation-tab__row", loading.then_some("moderation-tab__row--loading"))}
onclick={on_click}
role="button"
tabindex="0"
>
<span class="moderation-tab__col moderation-tab__col--text">
{ &r.text }
</span>
<span class="moderation-tab__col moderation-tab__col--author">
{ &r.author }
</span>
<span class="moderation-tab__col moderation-tab__col--count">
{ r.report_count }
</span>
<span class="moderation-tab__col moderation-tab__col--date">
{ date_display }
</span>
</div>
}
}
// ── 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<String>,
action_loading: bool,
on_close: &Callback<MouseEvent>,
on_delete: Callback<MouseEvent>,
on_hide: Callback<MouseEvent>,
on_dismiss: Callback<MouseEvent>,
) -> Html {
let quote = &details.quote;
let on_close = on_close.clone();
html! {
<div class="moderation-modal__overlay">
<div class="moderation-modal">
<div class="moderation-modal__header">
<h2 class="moderation-modal__title">{ "Report Details" }</h2>
<button
class="btn btn--muted moderation-modal__close"
onclick={on_close.clone()}
>
{ "✕" }
</button>
</div>
// ── Quote display ─────────────────────────────────────────────
<div class="moderation-modal__quote">
<blockquote class="moderation-modal__quote-text">
{ &quote.text }
</blockquote>
<p class="moderation-modal__quote-meta">
{ "— " }{ &quote.author }
if let Some(ref src) = quote.source {
{ format!(", {src}") }
}
if let Some(ref d) = quote.date {
{ format!(" ({d})") }
}
</p>
if !quote.tags.is_empty() {
<div class="moderation-modal__tags">
{ for quote.tags.iter().map(|t| html! {
<span class="quote-card__tag">{ t }</span>
}) }
</div>
}
if quote.hidden {
<span class="moderation-modal__hidden-badge">
{ "Hidden" }
</span>
}
</div>
// ── Individual reports ────────────────────────────────────────
<div class="moderation-modal__reports">
<h3 class="moderation-modal__reports-heading">
{ format!("{} report(s)", details.reports.len()) }
</h3>
if details.reports.is_empty() {
<p class="moderation-tab__empty">{ "No individual reports." }</p>
} else {
{ for details.reports.iter().map(|rep| {
let date = rep.created_at.get(..10).unwrap_or(&rep.created_at);
html! {
<div class="moderation-modal__report-row">
<span class="moderation-modal__report-date">{ date }</span>
<span class="moderation-modal__report-reason">
{ rep.reason.as_deref().unwrap_or("No reason given") }
</span>
</div>
}
}) }
}
</div>
// ── Feedback messages ─────────────────────────────────────────
if is_hide_success {
<p class="admin-section__success">
{ "Quote hidden. It will no longer appear in listings." }
</p>
}
if let Some(err) = modal_err {
<p class="admin-section__error">{ err }</p>
}
// ── Action buttons ────────────────────────────────────────────
<div class="moderation-modal__actions">
<button
class="btn btn--danger"
disabled={action_loading}
onclick={on_delete}
>
{ "Delete Quote" }
</button>
if !quote.hidden {
<button
class="btn btn--primary"
disabled={action_loading}
onclick={on_hide}
>
{ "Hide Quote" }
</button>
}
<button
class="btn"
disabled={action_loading}
onclick={on_dismiss}
>
{ "Dismiss Reports" }
</button>
<button
class="btn btn--muted"
onclick={on_close}
>
{ "Close" }
</button>
</div>
</div>
</div>
}
}