feat(quotesdb): merge full page implementations, tag filter, and stylesheet

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

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

@ -0,0 +1,601 @@
/* ============================================================
QuotesDB Main Stylesheet
Convention: BEM (Block__Element--Modifier)
Color scheme: neutral (gray scale with indigo accent)
============================================================ */
/* ── CSS Custom Properties ─────────────────────────────────── */
:root {
--color-bg: #f9f9f9;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--color-text: #1a202c;
--color-text-muted: #718096;
--color-accent: #4f46e5;
--color-accent-hover: #4338ca;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-success: #16a34a;
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
--color-code-bg: #f1f5f9;
--radius: 6px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "Fira Code", "Cascadia Code", "Consolas", monospace;
}
/* ── Base / Reset ──────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
min-height: 100vh;
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
h1 {
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 1rem;
}
h2 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
margin-bottom: 0.75rem;
}
blockquote {
font-size: 1.125rem;
font-style: italic;
color: var(--color-text);
border-left: 4px solid var(--color-accent);
padding: 0.5rem 1rem;
margin: 0;
background-color: var(--color-code-bg);
border-radius: 0 var(--radius) var(--radius) 0;
}
input, textarea {
font-family: inherit;
font-size: 1rem;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
width: 100%;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}
textarea {
resize: vertical;
min-height: 6rem;
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-code-bg);
padding: 0.1em 0.4em;
border-radius: 3px;
}
/* ── Navigation ────────────────────────────────────────────── */
.nav {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
box-shadow: var(--shadow);
}
.nav__brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-accent);
text-decoration: none;
}
.nav__brand:hover {
color: var(--color-accent-hover);
text-decoration: none;
}
.nav__links {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav__link {
font-size: 0.95rem;
color: var(--color-text);
font-weight: 500;
}
.nav__link:hover {
color: var(--color-accent);
text-decoration: none;
}
/* ── Main Content Container ────────────────────────────────── */
.main-content {
flex: 1;
max-width: 800px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.5rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.btn:hover {
background: var(--color-bg);
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #ffffff;
}
.btn--primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
color: #ffffff;
}
.btn--danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: #ffffff;
}
.btn--danger:hover {
background: var(--color-danger-hover);
border-color: var(--color-danger-hover);
color: #ffffff;
}
/* ── QuoteCard ─────────────────────────────────────────────── */
.quote-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
margin-bottom: 1.5rem;
}
.quote-card__text {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.quote-card__footer {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.quote-card__author {
font-weight: 600;
color: var(--color-accent);
text-decoration: none;
}
.quote-card__author:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.quote-card__source {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.quote-card__tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.quote-card__tag {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.15rem 0.65rem;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.quote-card__link {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-accent);
}
.quote-card__link:hover {
color: var(--color-accent-hover);
}
/* ── Error Display ─────────────────────────────────────────── */
.error-display {
background: var(--color-error-bg);
border: 1px solid var(--color-error-border);
border-radius: var(--radius);
padding: 0.875rem 1rem;
margin-bottom: 1rem;
}
.error-display__message {
color: var(--color-danger);
font-size: 0.95rem;
}
/* ── Pagination ────────────────────────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.pagination__btn {
padding: 0.4rem 1rem;
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s ease;
}
.pagination__btn:hover:not(:disabled) {
background: var(--color-bg);
border-color: var(--color-accent);
}
.pagination__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination__info {
font-size: 0.9rem;
color: var(--color-text-muted);
}
/* ── Auth Modal ────────────────────────────────────────────── */
.auth-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;
}
.auth-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: 400px;
}
.auth-modal__title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.auth-modal__input {
margin-bottom: 1rem;
}
.auth-modal__actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* ── Home Page ─────────────────────────────────────────────── */
.page-home {
text-align: center;
}
.page-home__title {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.page-home__subtitle {
color: var(--color-text-muted);
margin-bottom: 2rem;
}
.page-home__loading {
color: var(--color-text-muted);
font-style: italic;
}
.page-home__actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
/* ── Browse Page ───────────────────────────────────────────── */
.page-browse__title {
margin-bottom: 1.5rem;
}
.page-browse__filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.page-browse__filter-input {
max-width: 240px;
}
.page-browse__loading,
.page-browse__empty {
color: var(--color-text-muted);
font-style: italic;
}
.page-browse__list {
display: flex;
flex-direction: column;
}
/* ── Quote Detail Page ─────────────────────────────────────── */
.page-quote__loading {
color: var(--color-text-muted);
font-style: italic;
}
.page-quote__actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
/* Edit form */
.edit-form {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
margin-top: 1.5rem;
}
.edit-form__field {
margin-bottom: 1rem;
}
.edit-form__label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.4rem;
color: var(--color-text);
}
.edit-form__input,
.edit-form__textarea {
width: 100%;
}
.edit-form__actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
/* ── Author Page ───────────────────────────────────────────── */
.page-author__title {
margin-bottom: 1rem;
}
.page-author__filters {
margin-bottom: 1.5rem;
}
.page-author__filter-input {
max-width: 240px;
}
.page-author__loading,
.page-author__empty {
color: var(--color-text-muted);
font-style: italic;
}
.page-author__list {
display: flex;
flex-direction: column;
}
/* ── Submit Page ───────────────────────────────────────────── */
.page-submit__title {
margin-bottom: 1.5rem;
}
.submit-form {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
max-width: 600px;
}
.submit-form__field {
margin-bottom: 1.25rem;
}
.submit-form__label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.4rem;
color: var(--color-text);
}
.submit-form__input,
.submit-form__textarea {
width: 100%;
}
.submit-form__actions {
margin-top: 1.5rem;
}
/* Success state */
.page-submit--success {
text-align: left;
}
.page-submit__auth-code-box {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
font-size: 1rem;
}
.page-submit__auth-code {
font-family: var(--font-mono);
font-size: 1.1rem;
color: var(--color-accent);
font-weight: 600;
user-select: all;
}
.page-submit__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* ── 404 Page ──────────────────────────────────────────────── */
.page-not-found {
text-align: center;
padding-top: 4rem;
}
.page-not-found h1 {
color: var(--color-text-muted);
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 640px) {
h1 {
font-size: 1.5rem;
}
.main-content {
padding: 1rem;
}
.page-home__title {
font-size: 1.75rem;
}
.page-home__actions {
flex-direction: column;
align-items: center;
}
.page-browse__filters {
flex-direction: column;
}
.page-browse__filter-input,
.page-author__filter-input {
max-width: 100%;
}
.nav {
padding: 0.75rem 1rem;
}
.auth-modal {
padding: 1.5rem;
}
}
Loading…
Cancel
Save