feat(quotesdb): /admin page component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent a4d59b4371
commit 49f70cc5e8

@ -41,6 +41,9 @@ pub enum Route {
/// New quote submission form.
#[at("/submit")]
Submit,
/// Admin panel for managing submissions lock and auth codes.
#[at("/admin")]
Admin,
/// Catch-all 404 page.
#[not_found]
#[at("/404")]
@ -56,6 +59,7 @@ fn switch(routes: Route) -> Html {
Route::QuoteDetail { id } => html! { <pages::quote::QuotePage id={id} /> },
Route::Author { name } => html! { <pages::author::AuthorPage name={name} /> },
Route::Submit => html! { <pages::submit::SubmitPage /> },
Route::Admin => html! { <pages::admin::AdminPage /> },
Route::NotFound => html! {
<div class="page-not-found">
<h1>{ "404 — Page Not Found" }</h1>
@ -80,6 +84,7 @@ fn app() -> Html {
<div class="nav__links">
<Link<Route> to={Route::Browse} classes="nav__link">{ "Browse" }</Link<Route>>
<Link<Route> to={Route::Submit} classes="nav__link">{ "Submit" }</Link<Route>>
<Link<Route> to={Route::Admin} classes="nav__link">{ "Admin" }</Link<Route>>
</div>
</nav>
<main class="main-content">

@ -0,0 +1,281 @@
//! Admin page — manage submissions lock and reset auth codes.
//!
//! Provides a persistent admin auth code input, an auth code reset section,
//! and a submissions lock/unlock section. The lock state is fetched on mount
//! from `GET /api/status`.
use crate::api::{self, ApiError};
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlInputElement;
use yew::prelude::*;
/// Admin page component.
///
/// Holds all local admin state and renders three sections:
/// 1. A persistent admin auth code input shared by all actions.
/// 2. A "Reset auth code" section that calls `POST /api/admin/reset-auth-code`.
/// 3. A "Submissions" section that shows current lock state and allows toggling.
///
/// The submissions lock state is fetched from `GET /api/status` on mount.
#[function_component(AdminPage)]
pub fn admin_page() -> Html {
// Persistent admin auth code used by all admin actions.
let admin_code = use_state(String::new);
// Optional new passphrase for the reset section.
let new_passphrase = use_state(String::new);
// Newly returned auth code after a successful reset.
let reset_result: UseStateHandle<Option<String>> = use_state(|| None);
// Error message for the reset section.
let reset_error: UseStateHandle<Option<String>> = use_state(|| None);
// Current submissions lock state, fetched on mount.
let submissions_locked: UseStateHandle<Option<bool>> = use_state(|| None);
// Error message for the lock/unlock section.
let lock_error: UseStateHandle<Option<String>> = use_state(|| None);
// Disables buttons during in-flight requests.
let loading = use_state(|| false);
// Fetch submission lock state on mount.
{
let submissions_locked = submissions_locked.clone();
use_effect_with((), move |_| {
spawn_local(async move {
if let Ok(status) = api::get_status().await {
submissions_locked.set(Some(status.submissions_locked));
}
});
});
}
// --- Reset auth code handler ---
let on_reset = {
let admin_code = admin_code.clone();
let new_passphrase = new_passphrase.clone();
let reset_result = reset_result.clone();
let reset_error = reset_error.clone();
let loading = loading.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if *loading {
return;
}
let code = (*admin_code).clone();
let passphrase = (*new_passphrase).clone();
let reset_result = reset_result.clone();
let reset_error = reset_error.clone();
let loading = loading.clone();
loading.set(true);
reset_result.set(None);
reset_error.set(None);
spawn_local(async move {
let new_code_opt = if passphrase.is_empty() {
None
} else {
Some(passphrase.as_str())
};
match api::admin_reset_auth_code(&code, new_code_opt, &code).await {
Ok(new_code) => {
reset_result.set(Some(new_code));
reset_error.set(None);
}
Err(ApiError::Forbidden) => {
reset_error.set(Some("Wrong auth code.".to_string()));
}
Err(e) => {
reset_error.set(Some(format!("Error: {e}")));
}
}
loading.set(false);
});
})
};
// --- Lock submissions handler ---
let on_lock = {
let admin_code = admin_code.clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
let loading = loading.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if *loading {
return;
}
let code = (*admin_code).clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
let loading = loading.clone();
loading.set(true);
lock_error.set(None);
spawn_local(async move {
match api::admin_lock(&code).await {
Ok(_) => {
submissions_locked.set(Some(true));
lock_error.set(None);
}
Err(ApiError::Forbidden) => {
lock_error.set(Some("Wrong auth code.".to_string()));
}
Err(e) => {
lock_error.set(Some(format!("Error: {e}")));
}
}
loading.set(false);
});
})
};
// --- Unlock submissions handler ---
let on_unlock = {
let admin_code = admin_code.clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
let loading = loading.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if *loading {
return;
}
let code = (*admin_code).clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
let loading = loading.clone();
loading.set(true);
lock_error.set(None);
spawn_local(async move {
match api::admin_unlock(&code).await {
Ok(_) => {
submissions_locked.set(Some(false));
lock_error.set(None);
}
Err(ApiError::Forbidden) => {
lock_error.set(Some("Wrong auth code.".to_string()));
}
Err(e) => {
lock_error.set(Some(format!("Error: {e}")));
}
}
loading.set(false);
});
})
};
html! {
<div class="page-admin">
<h1 class="page-admin__title">{ "Admin" }</h1>
<div class="admin-section">
<label class="admin-section__label" for="admin_code">
{ "Admin auth code:" }
</label>
<input
id="admin_code"
class="admin-section__input"
type="text"
placeholder="word-word-word-word"
value={(*admin_code).clone()}
oninput={{
let admin_code = admin_code.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
admin_code.set(input.value());
})
}}
/>
</div>
<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>
<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(locked) => html! {
<>
<p class="admin-section__status">
{ "Status: " }
if locked {
<strong>{ "Closed" }</strong>
} else {
<strong>{ "Open" }</strong>
}
</p>
<div class="admin-section__actions">
if !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}
>
{ "Unlock submissions" }
</button>
}
</div>
</>
},
}
}
if let Some(err) = (*lock_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
</div>
}
}

@ -2,6 +2,7 @@
//!
//! Each page corresponds to a route in the [`crate::Route`] enum.
pub mod admin;
pub mod author;
pub mod browse;
pub mod home;

Loading…
Cancel
Save