feat(quotesdb): add admin moderation tab with report detail modal
Adds a Moderation tab to the admin page (visible after unlock) showing a paginated list of reported quotes. Clicking a row opens a detail modal with the full quote, all individual reports, and action buttons to delete the quote, hide it, or dismiss the reports. - api.rs: add ReportSummary, ReportListResponse, ReportRow, QuoteReports types and five admin_* async functions for the reports endpoints - components/moderation_tab.rs: new ModerationTab function_component with paginated list, row-click loading state, and an inline detail modal - components/mod.rs: expose moderation_tab module - pages/admin.rs: introduce AdminTab enum and tab bar; wrap existing settings content in the Settings tab; add Moderation tab rendering ModerationTab - style.css: add styles for admin tabs, moderation list table, and detail modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>quotesdb
parent
00a9a36510
commit
25adf3897f
@ -0,0 +1,554 @@
|
||||
//! 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, ApiError, 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("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! {
|
||||
<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">
|
||||
{ "e.text }
|
||||
</blockquote>
|
||||
<p class="moderation-modal__quote-meta">
|
||||
{ "— " }{ "e.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>
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue