From 5aa31b59f00a96faa026f2ff33a69ddfd124506d Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 3 Mar 2026 10:26:29 -0800 Subject: [PATCH] feat(quotesdb): implement all UI page components (Home, Browse, QuoteDetail, Author, Submit) - HomePage: fetches random quote on mount, displays with QuoteCard and browse/submit links - BrowsePage: paginated list with author and tag filter inputs, Pagination component - QuotePage: view/edit/delete with AuthModal gating, 403/404 handling, sessionStorage auth - AuthorPage: lists quotes by author with tag filter and pagination - SubmitPage: full form with all fields, success state showing auth code prominently - Tag filter (d3d502) integrated into Browse and Author pages Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/src/bin/ui/pages/author.rs | 107 +++++++- quotesdb/src/bin/ui/pages/browse.rs | 131 +++++++++- quotesdb/src/bin/ui/pages/home.rs | 65 ++++- quotesdb/src/bin/ui/pages/quote.rs | 388 +++++++++++++++++++++++++++- quotesdb/src/bin/ui/pages/submit.rs | 267 ++++++++++++++++++- 5 files changed, 944 insertions(+), 14 deletions(-) diff --git a/quotesdb/src/bin/ui/pages/author.rs b/quotesdb/src/bin/ui/pages/author.rs index 4300f32..9b1500a 100644 --- a/quotesdb/src/bin/ui/pages/author.rs +++ b/quotesdb/src/bin/ui/pages/author.rs @@ -1,15 +1,116 @@ //! Author page — all quotes by a specific author. + +use crate::api; +use crate::components::error::ErrorDisplay; +use crate::components::pagination::Pagination; +use crate::components::quote_card::QuoteCard; +use quotesdb::Quote; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlInputElement; use yew::prelude::*; /// Props for the [`AuthorPage`] component. #[derive(Properties, PartialEq)] pub struct AuthorPageProps { - /// The author name from the route parameter. + /// The author name from the route parameter (URL-decoded by yew-router). pub name: String, } -/// Author page component. Lists all quotes attributed to the given author. +/// Author page component. +/// +/// Lists all quotes attributed to the given author, with pagination support. +/// Also provides a tag filter to narrow results within the author's quotes. #[function_component(AuthorPage)] pub fn author_page(props: &AuthorPageProps) -> Html { - html! {

{ format!("Loading quotes by {}...", props.name) }

} + let name = props.name.clone(); + let page = use_state(|| 1u32); + let total_pages = use_state(|| 1u32); + let tag_filter = use_state(String::new); + let quotes: UseStateHandle> = use_state(Vec::new); + let error: UseStateHandle> = use_state(|| None); + let loading = use_state(|| true); + + { + let name = name.clone(); + let page = page.clone(); + let total_pages = total_pages.clone(); + let tag_filter = tag_filter.clone(); + let quotes = quotes.clone(); + let error = error.clone(); + let loading = loading.clone(); + let page_val = *page; + let tag_val = (*tag_filter).clone(); + use_effect_with((name.clone(), page_val, tag_val.clone()), move |_| { + loading.set(true); + error.set(None); + let tag = if tag_val.is_empty() { + None + } else { + Some(tag_val.clone()) + }; + spawn_local(async move { + match api::list_quotes(page_val, Some(&name), tag.as_deref()).await { + Ok(resp) => { + quotes.set(resp.quotes); + total_pages.set(resp.total_pages.max(1)); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } + } + }); + }); + } + + let on_page = { + let page = page.clone(); + Callback::from(move |p: u32| page.set(p)) + }; + + let on_tag_input = { + let tag_filter = tag_filter.clone(); + let page = page.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + tag_filter.set(input.value()); + page.set(1); + }) + }; + + html! { +
+

{ format!("Quotes by {}", props.name) }

+ +
+ +
+ + if *loading { +

{ "Loading..." }

+ } else if let Some(err) = (*error).clone() { + + } else if quotes.is_empty() { +

{ "No quotes found for this author." }

+ } else { +
+ { for (*quotes).iter().map(|q| html! { + + }) } +
+ + } +
+ } } diff --git a/quotesdb/src/bin/ui/pages/browse.rs b/quotesdb/src/bin/ui/pages/browse.rs index e67e500..2537b95 100644 --- a/quotesdb/src/bin/ui/pages/browse.rs +++ b/quotesdb/src/bin/ui/pages/browse.rs @@ -1,8 +1,133 @@ -//! Browse page — paginated quote list with author and tag filters. +//! Browse page — paginated quote list with author and tag filter controls. + +use crate::api; +use crate::components::error::ErrorDisplay; +use crate::components::pagination::Pagination; +use crate::components::quote_card::QuoteCard; +use quotesdb::Quote; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlInputElement; use yew::prelude::*; -/// Browse page component. Displays paginated quotes with filter controls. +/// Browse page component. +/// +/// Displays a paginated list of quotes. Supports filtering by author name +/// and tag. Fetches from the API whenever page, author, or tag state changes. #[function_component(BrowsePage)] pub fn browse_page() -> Html { - html! {

{ "Loading..." }

} + let page = use_state(|| 1u32); + let total_pages = use_state(|| 1u32); + let author_filter = use_state(String::new); + let tag_filter = use_state(String::new); + let quotes: UseStateHandle> = use_state(Vec::new); + let error: UseStateHandle> = use_state(|| None); + let loading = use_state(|| true); + + // Fetch quotes whenever page, author, or tag changes + { + let page = page.clone(); + let total_pages = total_pages.clone(); + let author_filter = author_filter.clone(); + let tag_filter = tag_filter.clone(); + let quotes = quotes.clone(); + let error = error.clone(); + let loading = loading.clone(); + let page_val = *page; + let author_val = (*author_filter).clone(); + let tag_val = (*tag_filter).clone(); + use_effect_with((page_val, author_val.clone(), tag_val.clone()), move |_| { + loading.set(true); + error.set(None); + let author = if author_val.is_empty() { + None + } else { + Some(author_val.clone()) + }; + let tag = if tag_val.is_empty() { + None + } else { + Some(tag_val.clone()) + }; + spawn_local(async move { + match api::list_quotes(page_val, author.as_deref(), tag.as_deref()).await { + Ok(resp) => { + quotes.set(resp.quotes); + total_pages.set(resp.total_pages.max(1)); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } + } + }); + }); + } + + let on_page = { + let page = page.clone(); + Callback::from(move |p: u32| page.set(p)) + }; + + let on_author_input = { + let author_filter = author_filter.clone(); + let page = page.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + author_filter.set(input.value()); + page.set(1); + }) + }; + + let on_tag_input = { + let tag_filter = tag_filter.clone(); + let page = page.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + tag_filter.set(input.value()); + page.set(1); + }) + }; + + html! { +
+

{ "Browse Quotes" }

+ +
+ + +
+ + if *loading { +

{ "Loading..." }

+ } else if let Some(err) = (*error).clone() { + + } else if quotes.is_empty() { +

{ "No quotes found." }

+ } else { +
+ { for (*quotes).iter().map(|q| html! { + + }) } +
+ + } +
+ } } diff --git a/quotesdb/src/bin/ui/pages/home.rs b/quotesdb/src/bin/ui/pages/home.rs index 1e61d32..49dedd1 100644 --- a/quotesdb/src/bin/ui/pages/home.rs +++ b/quotesdb/src/bin/ui/pages/home.rs @@ -1,8 +1,67 @@ -//! Home page — displays a random quote. +//! Home page — displays a random quote on load. + +use crate::api; +use crate::components::error::ErrorDisplay; +use crate::components::quote_card::QuoteCard; +use crate::Route; +use quotesdb::Quote; +use wasm_bindgen_futures::spawn_local; use yew::prelude::*; +use yew_router::prelude::*; -/// Home page component. Fetches and displays a random quote on mount. +/// Home page component. +/// +/// Fetches a random quote from the API on mount and displays it using +/// [`QuoteCard`]. Provides a "Browse all quotes" link to the browse page. #[function_component(HomePage)] pub fn home_page() -> Html { - html! {

{ "Loading..." }

} + let quote: UseStateHandle> = use_state(|| None); + let error: UseStateHandle> = use_state(|| None); + let loading = use_state(|| true); + + { + let quote = quote.clone(); + let error = error.clone(); + let loading = loading.clone(); + use_effect_with((), move |_| { + spawn_local(async move { + match api::get_random_quote().await { + Ok(q) => { + quote.set(Some(q)); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } + } + }); + }); + } + + html! { +
+

{ "QuotesDB" }

+

{ "A random quote for you:" }

+ + if *loading { +

{ "Loading..." }

+ } else if let Some(err) = (*error).clone() { + + } else if let Some(q) = (*quote).clone() { + + } else { +

{ "No quotes found." }

+ } + +
+ to={Route::Browse} classes="btn btn--primary"> + { "Browse all quotes" } + > + to={Route::Submit} classes="btn"> + { "Submit a quote" } + > +
+
+ } } diff --git a/quotesdb/src/bin/ui/pages/quote.rs b/quotesdb/src/bin/ui/pages/quote.rs index eecf158..b3fd968 100644 --- a/quotesdb/src/bin/ui/pages/quote.rs +++ b/quotesdb/src/bin/ui/pages/quote.rs @@ -1,15 +1,397 @@ //! Quote detail page — view, edit, and delete a single quote. + +use crate::api::{self, ApiError}; +use crate::components::auth_modal::AuthModal; +use crate::components::error::ErrorDisplay; +use crate::components::quote_card::QuoteCard; +use crate::{storage, Route}; +use quotesdb::{Quote, UpdateQuoteInput}; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlInputElement; use yew::prelude::*; +use yew_router::prelude::*; + +/// Represents which modal/action is currently active. +#[derive(PartialEq, Clone)] +enum Action { + /// No active action. + None, + /// Edit auth modal shown. + EditAuth, + /// Edit form shown (auth has been entered). + EditForm, + /// Delete auth modal shown. + DeleteAuth, +} /// Props for the [`QuotePage`] component. #[derive(Properties, PartialEq)] pub struct QuotePageProps { - /// The quote's unique ID from the route parameter. + /// The quote's unique ID extracted from the route parameter. pub id: String, } -/// Quote detail page. Fetches a single quote by ID and renders view/edit/delete actions. +/// Quote detail page. +/// +/// Fetches a quote by ID, renders it with [`QuoteCard`], and provides +/// Edit and Delete actions each guarded by the [`AuthModal`] component. +/// +/// - Edit: shows auth modal → edit form → `POST /api/quotes/:id` → re-fetch +/// - Delete: shows auth modal → `DELETE /api/quotes/:id` → navigate to `/browse` +/// - 403 errors clear the stored auth code and display an error message. +/// - 404 errors display a user-friendly "not found" message. #[function_component(QuotePage)] pub fn quote_page(props: &QuotePageProps) -> Html { - html! {

{ format!("Loading quote {}...", props.id) }

} + let id = props.id.clone(); + let quote: UseStateHandle> = use_state(|| None); + let error: UseStateHandle> = use_state(|| None); + let loading = use_state(|| true); + let action = use_state(|| Action::None); + let action_error: UseStateHandle> = use_state(|| None); + + // Edit form state — pre-filled from fetched quote + let edit_text = use_state(String::new); + let edit_author = use_state(String::new); + let edit_source = use_state(String::new); + let edit_date = use_state(String::new); + let edit_tags = use_state(String::new); + + let navigator = use_navigator().unwrap(); + + // Fetch the quote on mount (and re-fetch when id changes) + { + let id = id.clone(); + let quote = quote.clone(); + let error = error.clone(); + let loading = loading.clone(); + use_effect_with(id.clone(), move |_| { + loading.set(true); + error.set(None); + spawn_local(async move { + match api::get_quote(&id).await { + Ok(q) => { + quote.set(Some(q)); + loading.set(false); + } + Err(ApiError::Server { status: 404, .. }) => { + error.set(Some( + "Quote not found. It may have been deleted.".to_string(), + )); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } + } + }); + }); + } + + // --- Edit auth modal submitted --- + let on_edit_auth = { + let id = id.clone(); + let action = action.clone(); + let action_error = action_error.clone(); + let quote = quote.clone(); + let edit_text = edit_text.clone(); + let edit_author = edit_author.clone(); + let edit_source = edit_source.clone(); + let edit_date = edit_date.clone(); + let edit_tags = edit_tags.clone(); + Callback::from(move |code: String| { + // Pre-fill edit form with current values + if let Some(q) = (*quote).clone() { + edit_text.set(q.text.clone()); + edit_author.set(q.author.clone()); + edit_source.set(q.source.clone().unwrap_or_default()); + edit_date.set(q.date.clone().unwrap_or_default()); + edit_tags.set(q.tags.join(", ")); + } + storage::set_auth_code(&id, &code); + action_error.set(None); + action.set(Action::EditForm); + }) + }; + + // --- Edit form submitted --- + let on_edit_submit = { + let id = id.clone(); + let quote = quote.clone(); + let action = action.clone(); + let action_error = action_error.clone(); + let edit_text = edit_text.clone(); + let edit_author = edit_author.clone(); + let edit_source = edit_source.clone(); + let edit_date = edit_date.clone(); + let edit_tags = edit_tags.clone(); + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + let id = id.clone(); + let quote = quote.clone(); + let action = action.clone(); + let action_error = action_error.clone(); + let text = (*edit_text).clone(); + let author = (*edit_author).clone(); + let source = (*edit_source).clone(); + let date = (*edit_date).clone(); + let tags_str = (*edit_tags).clone(); + spawn_local(async move { + let auth_code = match storage::get_auth_code(&id) { + Some(c) => c, + None => { + action_error.set(Some( + "No auth code found. Please re-enter your auth code.".to_string(), + )); + action.set(Action::EditAuth); + return; + } + }; + let tags: Vec = tags_str + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + let input = UpdateQuoteInput { + text: if text.is_empty() { None } else { Some(text) }, + author: if author.is_empty() { + None + } else { + Some(author) + }, + source: if source.is_empty() { + None + } else { + Some(source) + }, + date: if date.is_empty() { None } else { Some(date) }, + tags: Some(tags), + }; + match api::update_quote(&id, &input, &auth_code).await { + Ok(updated) => { + storage::set_auth_code(&id, &auth_code); + quote.set(Some(updated)); + action.set(Action::None); + action_error.set(None); + } + Err(ApiError::Server { status: 403, .. }) => { + storage::clear_auth_code(&id); + action_error.set(Some("Wrong auth code. Please try again.".to_string())); + action.set(Action::EditAuth); + } + Err(e) => { + action_error.set(Some(e.to_string())); + } + } + }); + }) + }; + + // --- Delete auth modal submitted --- + let on_delete_auth = { + let id = id.clone(); + let action = action.clone(); + let action_error = action_error.clone(); + let navigator = navigator.clone(); + Callback::from(move |code: String| { + let id = id.clone(); + let action = action.clone(); + let action_error = action_error.clone(); + let navigator = navigator.clone(); + storage::set_auth_code(&id, &code); + spawn_local(async move { + match api::delete_quote(&id, &code).await { + Ok(()) => { + storage::clear_auth_code(&id); + navigator.push(&Route::Browse); + } + Err(ApiError::Server { status: 403, .. }) => { + storage::clear_auth_code(&id); + action_error.set(Some("Wrong auth code. Please try again.".to_string())); + action.set(Action::DeleteAuth); + } + Err(e) => { + action_error.set(Some(e.to_string())); + action.set(Action::None); + } + } + }); + }) + }; + + let on_cancel = { + let action = action.clone(); + let action_error = action_error.clone(); + Callback::from(move |_| { + action.set(Action::None); + action_error.set(None); + }) + }; + + html! { +
+ if *loading { +

{ "Loading..." }

+ } else if let Some(err) = (*error).clone() { + + } else if let Some(q) = (*quote).clone() { + + + if let Some(action_err) = (*action_error).clone() { + + } + + if *action == Action::None { +
+ + +
+ } + + if *action == Action::EditAuth { + + } + + if *action == Action::EditForm { +
+
+ +