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 <noreply@anthropic.com>quotesdb
parent
52e771e9c4
commit
183994b3dc
@ -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! { <p>{ format!("Loading quotes by {}...", props.name) }</p> }
|
||||
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<Vec<Quote>> = use_state(Vec::new);
|
||||
let error: UseStateHandle<Option<String>> = 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! {
|
||||
<div class="page-author">
|
||||
<h1 class="page-author__title">{ format!("Quotes by {}", props.name) }</h1>
|
||||
|
||||
<div class="page-author__filters">
|
||||
<input
|
||||
class="page-author__filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by tag..."
|
||||
value={(*tag_filter).clone()}
|
||||
oninput={on_tag_input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
if *loading {
|
||||
<p class="page-author__loading">{ "Loading..." }</p>
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
<ErrorDisplay message={err} />
|
||||
} else if quotes.is_empty() {
|
||||
<p class="page-author__empty">{ "No quotes found for this author." }</p>
|
||||
} else {
|
||||
<div class="page-author__list">
|
||||
{ for (*quotes).iter().map(|q| html! {
|
||||
<QuoteCard quote={q.clone()} />
|
||||
}) }
|
||||
</div>
|
||||
<Pagination
|
||||
page={*page}
|
||||
total_pages={*total_pages}
|
||||
on_page={on_page}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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! { <p>{ "Loading..." }</p> }
|
||||
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<Vec<Quote>> = use_state(Vec::new);
|
||||
let error: UseStateHandle<Option<String>> = 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! {
|
||||
<div class="page-browse">
|
||||
<h1 class="page-browse__title">{ "Browse Quotes" }</h1>
|
||||
|
||||
<div class="page-browse__filters">
|
||||
<input
|
||||
class="page-browse__filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by author..."
|
||||
value={(*author_filter).clone()}
|
||||
oninput={on_author_input}
|
||||
/>
|
||||
<input
|
||||
class="page-browse__filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by tag..."
|
||||
value={(*tag_filter).clone()}
|
||||
oninput={on_tag_input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
if *loading {
|
||||
<p class="page-browse__loading">{ "Loading..." }</p>
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
<ErrorDisplay message={err} />
|
||||
} else if quotes.is_empty() {
|
||||
<p class="page-browse__empty">{ "No quotes found." }</p>
|
||||
} else {
|
||||
<div class="page-browse__list">
|
||||
{ for (*quotes).iter().map(|q| html! {
|
||||
<QuoteCard quote={q.clone()} />
|
||||
}) }
|
||||
</div>
|
||||
<Pagination
|
||||
page={*page}
|
||||
total_pages={*total_pages}
|
||||
on_page={on_page}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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! { <p>{ "Loading..." }</p> }
|
||||
let quote: UseStateHandle<Option<Quote>> = use_state(|| None);
|
||||
let error: UseStateHandle<Option<String>> = 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! {
|
||||
<div class="page-home">
|
||||
<h1 class="page-home__title">{ "QuotesDB" }</h1>
|
||||
<p class="page-home__subtitle">{ "A random quote for you:" }</p>
|
||||
|
||||
if *loading {
|
||||
<p class="page-home__loading">{ "Loading..." }</p>
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
<ErrorDisplay message={err} />
|
||||
} else if let Some(q) = (*quote).clone() {
|
||||
<QuoteCard quote={q} />
|
||||
} else {
|
||||
<p>{ "No quotes found." }</p>
|
||||
}
|
||||
|
||||
<div class="page-home__actions">
|
||||
<Link<Route> to={Route::Browse} classes="btn btn--primary">
|
||||
{ "Browse all quotes" }
|
||||
</Link<Route>>
|
||||
<Link<Route> to={Route::Submit} classes="btn">
|
||||
{ "Submit a quote" }
|
||||
</Link<Route>>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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! { <p>{ format!("Loading quote {}...", props.id) }</p> }
|
||||
let id = props.id.clone();
|
||||
let quote: UseStateHandle<Option<Quote>> = use_state(|| None);
|
||||
let error: UseStateHandle<Option<String>> = use_state(|| None);
|
||||
let loading = use_state(|| true);
|
||||
let action = use_state(|| Action::None);
|
||||
let action_error: UseStateHandle<Option<String>> = 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<String> = 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! {
|
||||
<div class="page-quote">
|
||||
if *loading {
|
||||
<p class="page-quote__loading">{ "Loading..." }</p>
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
<ErrorDisplay message={err} />
|
||||
} else if let Some(q) = (*quote).clone() {
|
||||
<QuoteCard quote={q.clone()} />
|
||||
|
||||
if let Some(action_err) = (*action_error).clone() {
|
||||
<ErrorDisplay message={action_err} />
|
||||
}
|
||||
|
||||
if *action == Action::None {
|
||||
<div class="page-quote__actions">
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
onclick={{
|
||||
let action = action.clone();
|
||||
let id = id.clone();
|
||||
let action_error = action_error.clone();
|
||||
Callback::from(move |_| {
|
||||
action_error.set(None);
|
||||
action.set(Action::EditAuth);
|
||||
// If we have a stored code, skip straight to form
|
||||
if storage::get_auth_code(&id).is_some() {
|
||||
action.set(Action::EditForm);
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{ "Edit" }
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--danger"
|
||||
onclick={{
|
||||
let action = action.clone();
|
||||
let action_error = action_error.clone();
|
||||
Callback::from(move |_| {
|
||||
action_error.set(None);
|
||||
action.set(Action::DeleteAuth);
|
||||
})
|
||||
}}
|
||||
>
|
||||
{ "Delete" }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
if *action == Action::EditAuth {
|
||||
<AuthModal
|
||||
on_submit={on_edit_auth}
|
||||
on_cancel={on_cancel.clone()}
|
||||
prefill={storage::get_auth_code(&id)}
|
||||
/>
|
||||
}
|
||||
|
||||
if *action == Action::EditForm {
|
||||
<form class="edit-form" onsubmit={on_edit_submit}>
|
||||
<div class="edit-form__field">
|
||||
<label class="edit-form__label">{ "Text" }</label>
|
||||
<textarea
|
||||
class="edit-form__textarea"
|
||||
value={(*edit_text).clone()}
|
||||
oninput={{
|
||||
let edit_text = edit_text.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlTextAreaElement =
|
||||
e.target_unchecked_into();
|
||||
edit_text.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-form__field">
|
||||
<label class="edit-form__label">{ "Author" }</label>
|
||||
<input
|
||||
class="edit-form__input"
|
||||
type="text"
|
||||
value={(*edit_author).clone()}
|
||||
oninput={{
|
||||
let edit_author = edit_author.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement =
|
||||
e.target_unchecked_into();
|
||||
edit_author.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-form__field">
|
||||
<label class="edit-form__label">{ "Source (optional)" }</label>
|
||||
<input
|
||||
class="edit-form__input"
|
||||
type="text"
|
||||
value={(*edit_source).clone()}
|
||||
oninput={{
|
||||
let edit_source = edit_source.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement =
|
||||
e.target_unchecked_into();
|
||||
edit_source.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-form__field">
|
||||
<label class="edit-form__label">{ "Date (optional, YYYY-MM-DD)" }</label>
|
||||
<input
|
||||
class="edit-form__input"
|
||||
type="text"
|
||||
value={(*edit_date).clone()}
|
||||
oninput={{
|
||||
let edit_date = edit_date.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement =
|
||||
e.target_unchecked_into();
|
||||
edit_date.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-form__field">
|
||||
<label class="edit-form__label">{ "Tags (comma-separated)" }</label>
|
||||
<input
|
||||
class="edit-form__input"
|
||||
type="text"
|
||||
value={(*edit_tags).clone()}
|
||||
oninput={{
|
||||
let edit_tags = edit_tags.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement =
|
||||
e.target_unchecked_into();
|
||||
edit_tags.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-form__actions">
|
||||
<button type="submit" class="btn btn--primary">{ "Save" }</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick={{
|
||||
let on_cancel = on_cancel.clone();
|
||||
Callback::from(move |_| on_cancel.emit(()))
|
||||
}}
|
||||
>
|
||||
{ "Cancel" }
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
if *action == Action::DeleteAuth {
|
||||
<AuthModal
|
||||
on_submit={on_delete_auth}
|
||||
on_cancel={on_cancel.clone()}
|
||||
prefill={storage::get_auth_code(&id)}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
<p>{ "Quote not found." }</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,271 @@
|
||||
//! Submit page — new quote submission form.
|
||||
|
||||
use crate::api::{self, ApiError};
|
||||
use crate::components::error::ErrorDisplay;
|
||||
use crate::storage;
|
||||
use crate::Route;
|
||||
use quotesdb::CreateQuoteInput;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
/// Submit page component. Renders a form for creating a new quote.
|
||||
/// Submit page component.
|
||||
///
|
||||
/// Provides a form for creating a new quote with fields for text, author,
|
||||
/// source, date, tags, and an optional custom auth code. On success, displays
|
||||
/// the returned auth code prominently so the user can save it, and stores it
|
||||
/// in session storage for immediate use.
|
||||
#[function_component(SubmitPage)]
|
||||
pub fn submit_page() -> Html {
|
||||
html! { <p>{ "Quote submission form" }</p> }
|
||||
let text = use_state(String::new);
|
||||
let author = use_state(String::new);
|
||||
let source = use_state(String::new);
|
||||
let date = use_state(String::new);
|
||||
let tags = use_state(String::new);
|
||||
let custom_auth = use_state(String::new);
|
||||
let submitting = use_state(|| false);
|
||||
let error: UseStateHandle<Option<String>> = use_state(|| None);
|
||||
let success: UseStateHandle<Option<(String, String)>> = use_state(|| None); // (quote_id, auth_code)
|
||||
|
||||
let onsubmit = {
|
||||
let text = text.clone();
|
||||
let author = author.clone();
|
||||
let source = source.clone();
|
||||
let date = date.clone();
|
||||
let tags = tags.clone();
|
||||
let custom_auth = custom_auth.clone();
|
||||
let submitting = submitting.clone();
|
||||
let error = error.clone();
|
||||
let success = success.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
if *submitting {
|
||||
return;
|
||||
}
|
||||
let text_val = (*text).clone();
|
||||
let author_val = (*author).clone();
|
||||
let source_val = (*source).clone();
|
||||
let date_val = (*date).clone();
|
||||
let tags_val = (*tags).clone();
|
||||
let auth_val = (*custom_auth).clone();
|
||||
|
||||
if text_val.is_empty() || author_val.is_empty() {
|
||||
error.set(Some("Text and author are required.".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed_tags: Vec<String> = tags_val
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let input = CreateQuoteInput {
|
||||
text: text_val,
|
||||
author: author_val,
|
||||
source: if source_val.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(source_val)
|
||||
},
|
||||
date: if date_val.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(date_val)
|
||||
},
|
||||
tags: parsed_tags,
|
||||
auth_code: if auth_val.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(auth_val)
|
||||
},
|
||||
};
|
||||
|
||||
submitting.set(true);
|
||||
error.set(None);
|
||||
|
||||
let submitting = submitting.clone();
|
||||
let error = error.clone();
|
||||
let success = success.clone();
|
||||
spawn_local(async move {
|
||||
match api::create_quote(&input).await {
|
||||
Ok(resp) => {
|
||||
storage::set_auth_code(&resp.quote.id, &resp.auth_code);
|
||||
success.set(Some((resp.quote.id, resp.auth_code)));
|
||||
submitting.set(false);
|
||||
}
|
||||
Err(ApiError::Server { status, message }) => {
|
||||
error.set(Some(format!("Error {status}: {message}")));
|
||||
submitting.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
submitting.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
if let Some((quote_id, auth_code)) = (*success).clone() {
|
||||
return html! {
|
||||
<div class="page-submit page-submit--success">
|
||||
<h1 class="page-submit__title">{ "Quote Submitted!" }</h1>
|
||||
<p>{ "Your quote has been added. Save your auth code below — you'll need it to edit or delete this quote." }</p>
|
||||
<div class="page-submit__auth-code-box">
|
||||
<strong>{ "Auth Code: " }</strong>
|
||||
<code class="page-submit__auth-code">{ &auth_code }</code>
|
||||
</div>
|
||||
<div class="page-submit__actions">
|
||||
<Link<Route>
|
||||
to={Route::QuoteDetail { id: quote_id.clone() }}
|
||||
classes="btn btn--primary"
|
||||
>
|
||||
{ "View your quote" }
|
||||
</Link<Route>>
|
||||
<Link<Route> to={Route::Submit} classes="btn">
|
||||
{ "Submit another" }
|
||||
</Link<Route>>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="page-submit">
|
||||
<h1 class="page-submit__title">{ "Submit a Quote" }</h1>
|
||||
|
||||
if let Some(err) = (*error).clone() {
|
||||
<ErrorDisplay message={err} />
|
||||
}
|
||||
|
||||
<form class="submit-form" onsubmit={onsubmit}>
|
||||
<div class="submit-form__field">
|
||||
<label class="submit-form__label" for="text">{ "Quote text *" }</label>
|
||||
<textarea
|
||||
id="text"
|
||||
class="submit-form__textarea"
|
||||
placeholder="Enter the quote text..."
|
||||
value={(*text).clone()}
|
||||
oninput={{
|
||||
let text = text.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let el: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||
text.set(el.value());
|
||||
})
|
||||
}}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="submit-form__field">
|
||||
<label class="submit-form__label" for="author">{ "Author *" }</label>
|
||||
<input
|
||||
id="author"
|
||||
class="submit-form__input"
|
||||
type="text"
|
||||
placeholder="e.g. Mark Twain"
|
||||
value={(*author).clone()}
|
||||
oninput={{
|
||||
let author = author.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
author.set(input.value());
|
||||
})
|
||||
}}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="submit-form__field">
|
||||
<label class="submit-form__label" for="source">{ "Source (optional)" }</label>
|
||||
<input
|
||||
id="source"
|
||||
class="submit-form__input"
|
||||
type="text"
|
||||
placeholder="e.g. Adventures of Huckleberry Finn"
|
||||
value={(*source).clone()}
|
||||
oninput={{
|
||||
let source = source.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
source.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="submit-form__field">
|
||||
<label class="submit-form__label" for="date">{ "Date (optional, YYYY-MM-DD)" }</label>
|
||||
<input
|
||||
id="date"
|
||||
class="submit-form__input"
|
||||
type="text"
|
||||
placeholder="e.g. 1884-01-01"
|
||||
value={(*date).clone()}
|
||||
oninput={{
|
||||
let date = date.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
date.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="submit-form__field">
|
||||
<label class="submit-form__label" for="tags">{ "Tags (optional, comma-separated)" }</label>
|
||||
<input
|
||||
id="tags"
|
||||
class="submit-form__input"
|
||||
type="text"
|
||||
placeholder="e.g. humor, classic, american"
|
||||
value={(*tags).clone()}
|
||||
oninput={{
|
||||
let tags = tags.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
tags.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="submit-form__field">
|
||||
<label class="submit-form__label" for="auth_code">
|
||||
{ "Custom auth code (optional)" }
|
||||
</label>
|
||||
<input
|
||||
id="auth_code"
|
||||
class="submit-form__input"
|
||||
type="text"
|
||||
placeholder="word-word-word-word (auto-generated if empty)"
|
||||
value={(*custom_auth).clone()}
|
||||
oninput={{
|
||||
let custom_auth = custom_auth.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
custom_auth.set(input.value());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="submit-form__actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn--primary"
|
||||
disabled={*submitting}
|
||||
>
|
||||
if *submitting {
|
||||
{ "Submitting..." }
|
||||
} else {
|
||||
{ "Submit Quote" }
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue