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>
main
Elijah Voigt 3 months ago
parent 5fa045181e
commit 9cf1cf8228

@ -388,6 +388,187 @@ pub async fn report_quote(
}
}
/// Summary of a reported quote in the admin reports list.
///
/// Returned by `GET /api/admin/reports`.
#[derive(Debug, Clone, Deserialize)]
pub struct ReportSummary {
/// The ID of the reported quote.
pub quote_id: String,
/// Abbreviated quote text (first ~80 chars).
pub text: String,
/// Author of the reported quote.
pub author: String,
/// Total number of reports against this quote.
pub report_count: u32,
/// ISO timestamp of the most recent report.
pub latest_report_at: String,
}
/// Paginated list of reported quotes.
///
/// Returned by `GET /api/admin/reports?page=N`.
#[derive(Debug, Clone, Deserialize)]
pub struct ReportListResponse {
/// Summaries of reported quotes on this page.
pub reports: Vec<ReportSummary>,
/// Total number of pages.
pub total_pages: u32,
/// Current page number (1-based).
pub page: u32,
}
/// A single report row within a quote's report detail.
#[derive(Debug, Clone, Deserialize)]
pub struct ReportRow {
/// Unique report ID.
pub id: String,
/// Optional human-readable reason supplied by the reporter.
pub reason: Option<String>,
/// ISO timestamp when the report was created.
pub created_at: String,
}
/// Full details for a reported quote: the quote plus all individual reports.
///
/// Returned by `GET /api/admin/reports/:quote_id`.
#[derive(Debug, Clone, Deserialize)]
pub struct QuoteReports {
/// The full quote object.
pub quote: quotesdb::Quote,
/// All reports submitted against this quote, ordered oldest first.
pub reports: Vec<ReportRow>,
}
/// Fetch a paginated list of reported quotes.
///
/// Sends `X-Admin-Code: admin_code` in the request header.
/// Returns [`ReportListResponse`] on HTTP 200, or:
/// - [`ApiError::Forbidden`] on HTTP 403,
/// - [`ApiError::Server`] for other non-200 responses.
pub async fn admin_list_reports(
page: u32,
admin_code: &str,
) -> Result<ReportListResponse, ApiError> {
let resp = gloo::net::http::Request::get(&format!("/api/admin/reports?page={page}"))
.header("X-Admin-Code", admin_code)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
match resp.status() {
200 => resp
.json()
.await
.map_err(|e| ApiError::Parse(e.to_string())),
403 => Err(ApiError::Forbidden),
status => {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status,
message: msg,
})
}
}
}
/// Fetch full report details for a specific quote.
///
/// Sends `X-Admin-Code: admin_code` in the request header.
/// Returns [`QuoteReports`] on HTTP 200.
pub async fn admin_get_quote_reports(
quote_id: &str,
admin_code: &str,
) -> Result<QuoteReports, ApiError> {
let resp = gloo::net::http::Request::get(&format!("/api/admin/reports/{quote_id}"))
.header("X-Admin-Code", admin_code)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
match resp.status() {
200 => resp
.json()
.await
.map_err(|e| ApiError::Parse(e.to_string())),
403 => Err(ApiError::Forbidden),
status => {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status,
message: msg,
})
}
}
}
/// Delete a reported quote unconditionally (admin action).
///
/// Calls `DELETE /api/admin/reports/:quote_id/quote`.
/// Returns `Ok(())` on HTTP 204.
pub async fn admin_delete_reported_quote(quote_id: &str, admin_code: &str) -> Result<(), ApiError> {
let resp = gloo::net::http::Request::delete(&format!("/api/admin/reports/{quote_id}/quote"))
.header("X-Admin-Code", admin_code)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
match resp.status() {
204 => Ok(()),
403 => Err(ApiError::Forbidden),
status => {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status,
message: msg,
})
}
}
}
/// Hide a reported quote (admin action).
///
/// Calls `POST /api/admin/reports/:quote_id/hide`.
/// Returns `Ok(())` on HTTP 200.
pub async fn admin_hide_reported_quote(quote_id: &str, admin_code: &str) -> Result<(), ApiError> {
let resp = gloo::net::http::Request::post(&format!("/api/admin/reports/{quote_id}/hide"))
.header("X-Admin-Code", admin_code)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
match resp.status() {
200 => Ok(()),
403 => Err(ApiError::Forbidden),
status => {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status,
message: msg,
})
}
}
}
/// Dismiss all reports for a quote without deleting it (admin action).
///
/// Calls `DELETE /api/admin/reports/:quote_id/reports`.
/// Returns `Ok(())` on HTTP 204.
pub async fn admin_dismiss_reports(quote_id: &str, admin_code: &str) -> Result<(), ApiError> {
let resp = gloo::net::http::Request::delete(&format!("/api/admin/reports/{quote_id}/reports"))
.header("X-Admin-Code", admin_code)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
match resp.status() {
204 => Ok(()),
403 => Err(ApiError::Forbidden),
status => {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status,
message: msg,
})
}
}
}
/// Internal helper: GET a URL and deserialise the response body as JSON.
///
/// Returns `ApiError` on non-2xx status or deserialisation failure.

@ -4,6 +4,7 @@
pub mod auth_modal;
pub mod error;
pub mod moderation_tab;
pub mod pagination;
pub mod quote_card;
pub mod report_modal;

@ -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(&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>
}
}

@ -2,20 +2,35 @@
//!
//! The page renders in a locked state on load, showing only an auth code
//! input. Once the admin code is verified against the API, the full admin
//! controls are revealed. Refreshing the page resets to the locked state.
//! controls are revealed with two tabs:
//!
//! - **Settings** — submission lock/unlock and auth code reset.
//! - **Moderation** — paginated list of reported quotes with a detail modal.
//!
//! Refreshing the page resets to the locked state.
use crate::api::{self, ApiError};
use crate::components::moderation_tab::ModerationTab;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlInputElement;
use yew::prelude::*;
/// Which tab is active in the unlocked admin view.
#[derive(Clone, PartialEq)]
enum AdminTab {
/// Settings: submission lock and auth code management.
Settings,
/// Moderation: reported quotes list.
Moderation,
}
/// Admin page component.
///
/// Implements an auth-first flow:
/// - On load: locked state — shows only an auth code input and an "Unlock" button.
/// - On successful verification: unlocked state — shows all admin controls
/// (submission lock/unlock and auth code reset), passing the verified code
/// to each operation.
/// (submission lock/unlock, auth code reset, and a moderation tab), passing
/// the verified code to each operation.
/// - On failed verification (403): error message is shown; page remains locked.
/// - Refreshing always resets to locked — the code is kept in component state only.
#[function_component(AdminPage)]
@ -33,6 +48,8 @@ pub fn admin_page() -> Html {
let admin_code = use_state(String::new);
// --- Admin controls state (only used when unlocked) ---
// Active tab (settings or moderation).
let active_tab = use_state(|| AdminTab::Settings);
// Optional new passphrase for the reset section.
let new_passphrase = use_state(String::new);
// Newly returned auth code after a successful reset.
@ -274,93 +291,128 @@ pub fn admin_page() -> Html {
}
</div>
} else {
// --- Unlocked state: show all admin controls ---
// --- Unlocked state: tab bar + tab content ---
<hr class="admin-divider" />
<div class="admin-section">
<h2 class="admin-section__heading">{ "Reset auth code" }</h2>
<div class="admin-section__field">
<label class="admin-section__label" for="new_passphrase">
{ "New passphrase (optional):" }
</label>
<input
id="new_passphrase"
class="admin-section__input"
type="text"
placeholder="leave blank to auto-generate"
value={(*new_passphrase).clone()}
oninput={{
let new_passphrase = new_passphrase.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
new_passphrase.set(input.value());
})
}}
/>
</div>
<div class="admin-section__actions">
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_reset}
>
{ "Reset" }
</button>
</div>
if let Some(new_code) = (*reset_result).clone() {
<p class="admin-section__success">
{ "New code: " }
<code>{ new_code }</code>
</p>
}
if let Some(err) = (*reset_error).clone() {
<p class="admin-section__error">{ err }</p>
}
<div class="admin-tabs">
<button
class={classes!(
"admin-tabs__tab",
(*active_tab == AdminTab::Settings).then_some("admin-tabs__tab--active")
)}
onclick={{
let active_tab = active_tab.clone();
Callback::from(move |_: MouseEvent| active_tab.set(AdminTab::Settings))
}}
>
{ "Settings" }
</button>
<button
class={classes!(
"admin-tabs__tab",
(*active_tab == AdminTab::Moderation).then_some("admin-tabs__tab--active")
)}
onclick={{
let active_tab = active_tab.clone();
Callback::from(move |_: MouseEvent| active_tab.set(AdminTab::Moderation))
}}
>
{ "Moderation" }
</button>
</div>
<hr class="admin-divider" />
<div class="admin-tabs__content">
if *active_tab == AdminTab::Settings {
<hr class="admin-divider" />
<div class="admin-section">
<h2 class="admin-section__heading">{ "Submissions" }</h2>
{
match *submissions_locked {
None => html! { <p class="admin-section__status">{ "Loading status..." }</p> },
Some(is_locked) => html! {
<>
<p class="admin-section__status">
{ "Status: " }
if is_locked {
<strong>{ "Closed" }</strong>
} else {
<strong>{ "Open" }</strong>
}
</p>
<div class="admin-section__actions">
if !is_locked {
<button
class="btn btn--danger"
disabled={*loading}
onclick={on_lock}
>
{ "Lock submissions" }
</button>
} else {
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_unlock_submissions}
>
{ "Unlock submissions" }
</button>
}
</div>
</>
},
}
}
if let Some(err) = (*lock_error).clone() {
<p class="admin-section__error">{ err }</p>
<div class="admin-section">
<h2 class="admin-section__heading">{ "Reset auth code" }</h2>
<div class="admin-section__field">
<label class="admin-section__label" for="new_passphrase">
{ "New passphrase (optional):" }
</label>
<input
id="new_passphrase"
class="admin-section__input"
type="text"
placeholder="leave blank to auto-generate"
value={(*new_passphrase).clone()}
oninput={{
let new_passphrase = new_passphrase.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
new_passphrase.set(input.value());
})
}}
/>
</div>
<div class="admin-section__actions">
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_reset}
>
{ "Reset" }
</button>
</div>
if let Some(new_code) = (*reset_result).clone() {
<p class="admin-section__success">
{ "New code: " }
<code>{ new_code }</code>
</p>
}
if let Some(err) = (*reset_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
<hr class="admin-divider" />
<div class="admin-section">
<h2 class="admin-section__heading">{ "Submissions" }</h2>
{
match *submissions_locked {
None => html! { <p class="admin-section__status">{ "Loading status..." }</p> },
Some(is_locked) => html! {
<>
<p class="admin-section__status">
{ "Status: " }
if is_locked {
<strong>{ "Closed" }</strong>
} else {
<strong>{ "Open" }</strong>
}
</p>
<div class="admin-section__actions">
if !is_locked {
<button
class="btn btn--danger"
disabled={*loading}
onclick={on_lock}
>
{ "Lock submissions" }
</button>
} else {
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_unlock_submissions}
>
{ "Unlock submissions" }
</button>
}
</div>
</>
},
}
}
if let Some(err) = (*lock_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
} else {
// --- Moderation tab ---
<hr class="admin-divider" />
<ModerationTab admin_code={(*admin_code).clone()} />
}
</div>
}

@ -749,6 +749,253 @@ code {
font-size: 0.95rem;
}
/* ── Admin Page ────────────────────────────────────────────── */
.admin-section__success {
color: var(--color-success);
font-size: 0.95rem;
margin-top: 0.75rem;
}
/* ── Admin Tabs ────────────────────────────────────────────── */
.admin-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 2px solid var(--color-border);
margin-top: 1.5rem;
}
.admin-tabs__tab {
padding: 0.5rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-family: inherit;
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.admin-tabs__tab:hover {
color: var(--color-text);
}
.admin-tabs__tab--active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.admin-tabs__content {
margin-top: 0;
}
/* ── Moderation Tab ────────────────────────────────────────── */
.moderation-tab {
margin-top: 0.5rem;
}
.moderation-tab__loading,
.moderation-tab__empty {
color: var(--color-text-muted);
font-style: italic;
margin-top: 1rem;
}
.moderation-tab__list {
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
margin-top: 1rem;
}
.moderation-tab__header-row {
display: flex;
gap: 0.75rem;
padding: 0.6rem 1rem;
background: var(--color-code-bg);
border-bottom: 1px solid var(--color-border);
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.moderation-tab__row {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.12s ease;
align-items: baseline;
}
.moderation-tab__row:last-child {
border-bottom: none;
}
.moderation-tab__row:hover {
background: var(--color-bg);
}
.moderation-tab__row--loading {
opacity: 0.6;
cursor: wait;
}
.moderation-tab__col {
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moderation-tab__col--text {
flex: 2;
min-width: 0;
}
.moderation-tab__col--author {
flex: 1;
min-width: 0;
color: var(--color-text-muted);
}
.moderation-tab__col--count {
flex: 0 0 5rem;
text-align: right;
font-weight: 600;
color: var(--color-danger);
}
.moderation-tab__col--date {
flex: 0 0 7rem;
text-align: right;
color: var(--color-text-muted);
}
/* ── Moderation Detail Modal ───────────────────────────────── */
.moderation-modal__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.moderation-modal {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
padding: 2rem;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.moderation-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.moderation-modal__title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0;
}
.moderation-modal__close {
padding: 0.3rem 0.7rem;
font-size: 1rem;
}
.moderation-modal__quote {
background: var(--color-code-bg);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.moderation-modal__quote-text {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.moderation-modal__quote-meta {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 0;
}
.moderation-modal__tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.moderation-modal__hidden-badge {
display: inline-block;
margin-top: 0.5rem;
background: #fefce8;
border: 1px solid #fde047;
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
color: #854d0e;
font-weight: 500;
}
.moderation-modal__reports {
margin-bottom: 1.25rem;
}
.moderation-modal__reports-heading {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--color-text-muted);
}
.moderation-modal__report-row {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.moderation-modal__report-row:last-child {
border-bottom: none;
}
.moderation-modal__report-date {
flex: 0 0 6.5rem;
color: var(--color-text-muted);
}
.moderation-modal__report-reason {
flex: 1;
}
.moderation-modal__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 640px) {
h1 {

Loading…
Cancel
Save