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.
|
//! 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::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
/// Props for the [`AuthorPage`] component.
|
/// Props for the [`AuthorPage`] component.
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct AuthorPageProps {
|
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,
|
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)]
|
#[function_component(AuthorPage)]
|
||||||
pub fn author_page(props: &AuthorPageProps) -> Html {
|
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::*;
|
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)]
|
#[function_component(BrowsePage)]
|
||||||
pub fn browse_page() -> Html {
|
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::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)]
|
#[function_component(HomePage)]
|
||||||
pub fn home_page() -> Html {
|
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.
|
//! 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::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.
|
/// Props for the [`QuotePage`] component.
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct QuotePageProps {
|
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,
|
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)]
|
#[function_component(QuotePage)]
|
||||||
pub fn quote_page(props: &QuotePageProps) -> Html {
|
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.
|
//! 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::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)]
|
#[function_component(SubmitPage)]
|
||||||
pub fn submit_page() -> Html {
|
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