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