From 25adf3897f44ba717bb5665fef58a3d3c8b691c6 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 8 Mar 2026 20:38:46 -0700 Subject: [PATCH] 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 --- quotesdb/src/bin/ui/api.rs | 181 ++++++ quotesdb/src/bin/ui/components/mod.rs | 1 + .../src/bin/ui/components/moderation_tab.rs | 554 ++++++++++++++++++ quotesdb/src/bin/ui/pages/admin.rs | 224 ++++--- quotesdb/src/bin/ui/style.css | 247 ++++++++ 5 files changed, 1121 insertions(+), 86 deletions(-) create mode 100644 quotesdb/src/bin/ui/components/moderation_tab.rs diff --git a/quotesdb/src/bin/ui/api.rs b/quotesdb/src/bin/ui/api.rs index 01fb23b..2a076e0 100644 --- a/quotesdb/src/bin/ui/api.rs +++ b/quotesdb/src/bin/ui/api.rs @@ -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, + /// 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, + /// 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, +} + +/// 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 { + 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 { + 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. diff --git a/quotesdb/src/bin/ui/components/mod.rs b/quotesdb/src/bin/ui/components/mod.rs index 412ff88..9cc8d52 100644 --- a/quotesdb/src/bin/ui/components/mod.rs +++ b/quotesdb/src/bin/ui/components/mod.rs @@ -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; diff --git a/quotesdb/src/bin/ui/components/moderation_tab.rs b/quotesdb/src/bin/ui/components/moderation_tab.rs new file mode 100644 index 0000000..b1b2781 --- /dev/null +++ b/quotesdb/src/bin/ui/components/moderation_tab.rs @@ -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! { +//! +//! } +//! ``` + +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> = 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 { + + } + + +
+
+
+ } +} diff --git a/quotesdb/src/bin/ui/pages/admin.rs b/quotesdb/src/bin/ui/pages/admin.rs index ef7264f..cdfec29 100644 --- a/quotesdb/src/bin/ui/pages/admin.rs +++ b/quotesdb/src/bin/ui/pages/admin.rs @@ -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 { } } else { - // --- Unlocked state: show all admin controls --- + // --- Unlocked state: tab bar + tab content --- -
- -
-

{ "Reset auth code" }

-
- - -
-
- -
- if let Some(new_code) = (*reset_result).clone() { -

- { "New code: " } - { new_code } -

- } - if let Some(err) = (*reset_error).clone() { -

{ err }

- } +
+ +
-
+
+ if *active_tab == AdminTab::Settings { +
-
-

{ "Submissions" }

- { - match *submissions_locked { - None => html! {

{ "Loading status..." }

}, - Some(is_locked) => html! { - <> -

- { "Status: " } - if is_locked { - { "Closed" } - } else { - { "Open" } - } -

-
- if !is_locked { - - } else { - - } -
- - }, - } - } - if let Some(err) = (*lock_error).clone() { -

{ err }

+
+

{ "Reset auth code" }

+
+ + +
+
+ +
+ if let Some(new_code) = (*reset_result).clone() { +

+ { "New code: " } + { new_code } +

+ } + if let Some(err) = (*reset_error).clone() { +

{ err }

+ } +
+ +
+ +
+

{ "Submissions" }

+ { + match *submissions_locked { + None => html! {

{ "Loading status..." }

}, + Some(is_locked) => html! { + <> +

+ { "Status: " } + if is_locked { + { "Closed" } + } else { + { "Open" } + } +

+
+ if !is_locked { + + } else { + + } +
+ + }, + } + } + if let Some(err) = (*lock_error).clone() { +

{ err }

+ } +
+ } else { + // --- Moderation tab --- +
+ }
} diff --git a/quotesdb/src/bin/ui/style.css b/quotesdb/src/bin/ui/style.css index e949730..715482a 100644 --- a/quotesdb/src/bin/ui/style.css +++ b/quotesdb/src/bin/ui/style.css @@ -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 {