feat(quotesdb): merge full page implementations, tag filter, and stylesheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>quotesdb
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…
Reference in New Issue