feat(quotesdb): date range filter for quotes list

Add 6 optional query parameters to GET /api/quotes:
  date_after_year/month/day and date_before_year/month/day

Changes:
- QuoteRepository::list_quotes gains date_after and date_before params
- NativeRepository and D1Repository build ISO date prefix WHERE clauses;
  quotes with NULL date are excluded when any bound is set
- list_handler validates component ordering (month requires year, etc.)
  and returns 400 on invalid combinations
- build_date_bound helper converts y/m/d components to ISO prefix strings
- UI api::list_quotes and browse page gain From/To year filter inputs
- author page call updated to pass None for the new date params
- openapi.yaml extended with 6 new query parameter entries
- 6 new integration tests covering after, before, range, and 400 cases
- 1 new native DB unit test covering all filter combinations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent caf2246bff
commit fb93483f5c

@ -236,6 +236,54 @@ paths:
required: false
schema:
type: string
- name: date_after_year
in: query
description: Only include quotes dated on or after this year.
required: false
schema:
type: integer
minimum: 0
maximum: 9999
- name: date_after_month
in: query
description: Narrows after-bound to this month (112). Requires date_after_year.
required: false
schema:
type: integer
minimum: 1
maximum: 12
- name: date_after_day
in: query
description: Narrows after-bound to this day (131). Requires date_after_year and date_after_month.
required: false
schema:
type: integer
minimum: 1
maximum: 31
- name: date_before_year
in: query
description: Only include quotes dated on or before this year.
required: false
schema:
type: integer
minimum: 0
maximum: 9999
- name: date_before_month
in: query
description: Narrows before-bound to this month (112). Requires date_before_year.
required: false
schema:
type: integer
minimum: 1
maximum: 12
- name: date_before_day
in: query
description: Narrows before-bound to this day (131). Requires date_before_year and date_before_month.
required: false
schema:
type: integer
minimum: 1
maximum: 31
responses:
"200":
description: Paginated list of quotes.

@ -124,9 +124,12 @@ impl QuoteRepository for D1Repository {
Ok(())
}
/// List quotes with optional author/tag filters and 1-based pagination.
/// List quotes with optional author/tag/date filters and 1-based pagination.
///
/// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering.
/// `date_after` and `date_before` are ISO date prefix strings compared via
/// `>=` / `<=` against the stored `date` column; rows where `date IS NULL`
/// are excluded when either bound is set.
/// Tags for each returned quote are fetched in a second query per quote to
/// avoid duplicate rows from a JOIN.
async fn list_quotes(
@ -134,6 +137,8 @@ impl QuoteRepository for D1Repository {
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError> {
const PAGE_SIZE: u32 = 10;
let page = page.max(1);
@ -155,6 +160,20 @@ impl QuoteRepository for D1Repository {
binds.push(JsValue::from_str(t));
param_idx += 1;
}
// Exclude NULL dates when any date bound is active
if date_after.is_some() || date_before.is_some() {
conditions.push("q.date IS NOT NULL".to_owned());
}
if let Some(da) = date_after {
conditions.push(format!("q.date >= ?{param_idx}"));
binds.push(JsValue::from_str(da));
param_idx += 1;
}
if let Some(db) = date_before {
conditions.push(format!("q.date <= ?{param_idx}"));
binds.push(JsValue::from_str(db));
param_idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()

@ -92,12 +92,16 @@ pub trait QuoteRepository {
/// List quotes with optional filtering and pagination.
///
/// Page numbers are 1-based. Returns an empty `quotes` vec when `page`
/// is beyond the last page.
/// is beyond the last page. `date_after` and `date_before` are ISO date
/// prefix strings (e.g. `"2020"`, `"2020-06"`, `"2020-06-15"`); the DB
/// layer uses `>=` / `<=` comparisons against the stored `date` column.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError>;
/// Retrieve a single quote by its ID.

@ -75,9 +75,12 @@ impl QuoteRepository for NativeRepository {
.map_err(|e| DbError::Internal(e.to_string()))
}
/// List quotes with optional author/tag filters and 1-based pagination.
/// List quotes with optional author/tag/date filters and 1-based pagination.
///
/// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering.
/// `date_after` and `date_before` are ISO date prefix strings compared via
/// `>=` / `<=` against the stored `date` column; rows where `date IS NULL`
/// are excluded when either bound is set.
/// Tags for each returned quote are fetched in a second query per quote to
/// avoid duplicate rows from a JOIN.
async fn list_quotes(
@ -85,10 +88,14 @@ impl QuoteRepository for NativeRepository {
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError> {
let page = page.max(1);
let author = author.map(|s| s.to_owned());
let tag = tag.map(|s| s.to_owned());
let date_after = date_after.map(|s| s.to_owned());
let date_before = date_before.map(|s| s.to_owned());
self.conn
.call(move |conn| {
@ -103,6 +110,16 @@ impl QuoteRepository for NativeRepository {
conditions
.push("q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?)".to_owned());
}
// Exclude NULL dates when any date bound is active
if date_after.is_some() || date_before.is_some() {
conditions.push("q.date IS NOT NULL".to_owned());
}
if date_after.is_some() {
conditions.push("q.date >= ?".to_owned());
}
if date_before.is_some() {
conditions.push("q.date <= ?".to_owned());
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
@ -117,6 +134,12 @@ impl QuoteRepository for NativeRepository {
if let Some(ref t) = tag {
params.push(Box::new(t.clone()));
}
if let Some(ref da) = date_after {
params.push(Box::new(da.clone()));
}
if let Some(ref db) = date_before {
params.push(Box::new(db.clone()));
}
// ── Count total matching rows ──────────────────────────────
let count_sql = format!("SELECT COUNT(*) FROM quotes q {where_clause}");
@ -141,7 +164,7 @@ impl QuoteRepository for NativeRepository {
LIMIT ? OFFSET ?"
);
// Re-collect bound params (page + limit/offset appended)
// Re-collect bound params (limit/offset appended at end)
let mut params2: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(ref a) = author {
params2.push(Box::new(a.clone()));
@ -149,6 +172,12 @@ impl QuoteRepository for NativeRepository {
if let Some(ref t) = tag {
params2.push(Box::new(t.clone()));
}
if let Some(ref da) = date_after {
params2.push(Box::new(da.clone()));
}
if let Some(ref db) = date_before {
params2.push(Box::new(db.clone()));
}
params2.push(Box::new(PAGE_SIZE));
params2.push(Box::new(offset));
@ -541,12 +570,12 @@ mod tests {
.await
.unwrap();
}
let page1 = repo.list_quotes(1, None, None).await.unwrap();
let page1 = repo.list_quotes(1, None, None, None, None).await.unwrap();
assert_eq!(page1.quotes.len(), 10);
assert_eq!(page1.total_count, 15);
assert_eq!(page1.total_pages, 2);
let page2 = repo.list_quotes(2, None, None).await.unwrap();
let page2 = repo.list_quotes(2, None, None, None, None).await.unwrap();
assert_eq!(page2.quotes.len(), 5);
}
@ -558,7 +587,10 @@ mod tests {
.await
.unwrap();
}
let result = repo.list_quotes(1, Some("alice"), None).await.unwrap();
let result = repo
.list_quotes(1, Some("alice"), None, None, None)
.await
.unwrap();
// COLLATE NOCASE should match "Alice" and "alice"
assert_eq!(result.total_count, 2);
}
@ -580,11 +612,68 @@ mod tests {
.await
.unwrap();
let result = repo.list_quotes(1, None, Some("rust")).await.unwrap();
let result = repo
.list_quotes(1, None, Some("rust"), None, None)
.await
.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.quotes[0].text, "Tagged");
}
#[tokio::test]
async fn test_list_quotes_date_filter() {
let repo = in_memory_repo().await;
// Insert quotes with specific dates and one without a date
for (text, date) in &[
("Old quote", Some("1990-01-01")),
("Mid quote", Some("2000-06-15")),
("New quote", Some("2020-12-31")),
("No date quote", None),
] {
repo.create_quote(CreateQuoteInput {
text: text.to_string(),
author: "Author".to_owned(),
source: None,
date: date.map(|d| d.to_owned()),
tags: vec![],
auth_code: None,
})
.await
.unwrap();
}
// date_after only — should match 2000 and 2020
let result = repo
.list_quotes(1, None, None, Some("2000"), None)
.await
.unwrap();
assert_eq!(result.total_count, 2);
// date_before only — should match 1990 and 2000
let result = repo
.list_quotes(1, None, None, None, Some("2000-12-31"))
.await
.unwrap();
assert_eq!(result.total_count, 2);
// both bounds — should match only 2000
let result = repo
.list_quotes(1, None, None, Some("2000"), Some("2010"))
.await
.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.quotes[0].text, "Mid quote");
// No date quotes are excluded when a bound is active
let result_all = repo.list_quotes(1, None, None, None, None).await.unwrap();
assert_eq!(result_all.total_count, 4); // includes "No date quote"
let result_bounded = repo
.list_quotes(1, None, None, Some("1900"), None)
.await
.unwrap();
assert_eq!(result_bounded.total_count, 3); // "No date quote" excluded
}
#[tokio::test]
async fn test_random_quote_empty() {
let repo = in_memory_repo().await;

@ -82,12 +82,65 @@ struct ListParams {
author: Option<String>,
/// Filter by tag.
tag: Option<String>,
/// Only include quotes dated on or after this year.
date_after_year: Option<u16>,
/// Narrows after-bound to this month (112). Requires `date_after_year`.
date_after_month: Option<u8>,
/// Narrows after-bound to this day (131). Requires `date_after_year` and `date_after_month`.
date_after_day: Option<u8>,
/// Only include quotes dated on or before this year.
date_before_year: Option<u16>,
/// Narrows before-bound to this month (112). Requires `date_before_year`.
date_before_month: Option<u8>,
/// Narrows before-bound to this day (131). Requires `date_before_year` and `date_before_month`.
date_before_day: Option<u8>,
}
fn default_page() -> u32 {
1
}
/// Build an ISO date prefix string from optional year/month/day components.
///
/// Returns `None` if no year is given. For before-bounds, missing month
/// defaults to `12` and missing day defaults to `31` so the bound is
/// inclusive of the entire specified year/month.
///
/// # Examples
///
/// ```ignore
/// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string()));
/// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string()));
/// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string()));
/// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string()));
/// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None);
/// ```
fn build_date_bound(
year: Option<u16>,
month: Option<u8>,
day: Option<u8>,
is_before: bool,
) -> Option<String> {
match (year, month, day) {
(None, _, _) => None,
(Some(y), None, _) => {
if is_before {
Some(format!("{y:04}-12-31"))
} else {
Some(format!("{y:04}"))
}
}
(Some(y), Some(m), None) => {
if is_before {
Some(format!("{y:04}-{m:02}-31"))
} else {
Some(format!("{y:04}-{m:02}"))
}
}
(Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")),
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// `GET /api/` — return the OpenAPI specification as JSON.
@ -108,12 +161,65 @@ async fn openapi_handler() -> Response {
/// `GET /api/quotes` — list quotes with optional filtering and pagination.
///
/// Accepts `?page=N&author=X&tag=Y` query parameters. Defaults to page 1 and
/// no filters. Returns [`crate::db::ListResult`] serialised as JSON.
/// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and
/// month/day variants) query parameters. Defaults to page 1 and no filters.
/// Returns [`crate::db::ListResult`] serialised as JSON.
///
/// Returns `400 Bad Request` when date component ordering is violated (e.g.
/// `date_after_month` provided without `date_after_year`).
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response {
// Validate: month requires year, day requires year+month
if params.date_after_month.is_some() && params.date_after_year.is_none() {
return error_response(
StatusCode::BAD_REQUEST,
"date_after_month requires date_after_year",
);
}
if params.date_after_day.is_some()
&& (params.date_after_year.is_none() || params.date_after_month.is_none())
{
return error_response(
StatusCode::BAD_REQUEST,
"date_after_day requires date_after_year and date_after_month",
);
}
if params.date_before_month.is_some() && params.date_before_year.is_none() {
return error_response(
StatusCode::BAD_REQUEST,
"date_before_month requires date_before_year",
);
}
if params.date_before_day.is_some()
&& (params.date_before_year.is_none() || params.date_before_month.is_none())
{
return error_response(
StatusCode::BAD_REQUEST,
"date_before_day requires date_before_year and date_before_month",
);
}
let date_after = build_date_bound(
params.date_after_year,
params.date_after_month,
params.date_after_day,
false,
);
let date_before = build_date_bound(
params.date_before_year,
params.date_before_month,
params.date_before_day,
true,
);
match repo
.list_quotes(params.page, params.author.as_deref(), params.tag.as_deref())
.list_quotes(
params.page,
params.author.as_deref(),
params.tag.as_deref(),
date_after.as_deref(),
date_before.as_deref(),
)
.await
{
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
@ -291,6 +397,8 @@ mod tests {
page: u32,
_author: Option<&str>,
_tag: Option<&str>,
_date_after: Option<&str>,
_date_before: Option<&str>,
) -> Result<ListResult, DbError> {
let quotes = self.quotes.lock().unwrap();
let all: Vec<Quote> = quotes.iter().map(|(q, _)| q.clone()).collect();
@ -1320,4 +1428,134 @@ mod integration_tests {
// The random handler returns the full Quote, not a CreateResponse
assert!(v.get("id").is_some(), "should be a Quote, not an error");
}
// ── Date range filter integration tests ───────────────────────────────────
/// Create a quote with a specific date via PUT /api/quotes.
async fn create_quote_with_date(
app: Router,
text: &str,
date: Option<&str>,
) -> (Router, serde_json::Value, String) {
let mut payload = json!({
"text": text,
"author": "DateAuthor",
"tags": [],
});
if let Some(d) = date {
payload["date"] = json!(d);
}
let req = Request::builder()
.method(Method::PUT)
.uri("/api/quotes")
.header("Content-Type", "application/json")
.body(Body::from(payload.to_string()))
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app.clone(), req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let v = body_json(resp).await;
let auth_code = v["auth_code"].as_str().unwrap().to_owned();
let quote = v["quote"].clone();
(app, quote, auth_code)
}
/// `?date_after_year=` filters out quotes dated before that year.
#[tokio::test]
async fn integration_date_filter_after_year() {
let (app, _f) = test_router().await;
let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await;
let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await;
let (app, _, _) = create_quote_with_date(app, "No date quote", None).await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes?date_after_year=2000")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
// Only the 2020 quote qualifies; 1990 is before 2000, no-date excluded
assert_eq!(v["total_count"], 1);
assert_eq!(v["quotes"][0]["text"], "New quote");
}
/// `?date_before_year=` filters out quotes dated after that year.
#[tokio::test]
async fn integration_date_filter_before_year() {
let (app, _f) = test_router().await;
let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await;
let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-06-15")).await;
let (app, _, _) = create_quote_with_date(app, "No date quote", None).await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes?date_before_year=2000")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
// Only the 1990 quote qualifies; 2020 is after 2000-12-31, no-date excluded
assert_eq!(v["total_count"], 1);
assert_eq!(v["quotes"][0]["text"], "Old quote");
}
/// `?date_after_year=&date_before_year=` combined bounds narrow the window.
#[tokio::test]
async fn integration_date_filter_range() {
let (app, _f) = test_router().await;
let (app, _, _) = create_quote_with_date(app, "Old quote", Some("1990-01-01")).await;
let (app, _, _) = create_quote_with_date(app, "Mid quote", Some("2000-06-15")).await;
let (app, _, _) = create_quote_with_date(app, "New quote", Some("2020-12-31")).await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes?date_after_year=1995&date_before_year=2010")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["total_count"], 1);
assert_eq!(v["quotes"][0]["text"], "Mid quote");
}
/// `?date_after_month=` without a year returns 400 Bad Request.
#[tokio::test]
async fn integration_date_filter_month_without_year_returns_400() {
let (app, _f) = test_router().await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes?date_after_month=6")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// `?date_before_day=` without year+month returns 400 Bad Request.
#[tokio::test]
async fn integration_date_filter_day_without_year_month_returns_400() {
let (app, _f) = test_router().await;
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes?date_before_day=15")
.body(Body::empty())
.unwrap();
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}

@ -52,10 +52,14 @@ pub enum ApiError {
/// - `page` — 1-indexed page number.
/// - `author` — Optional author name filter (URL-encoded automatically).
/// - `tag` — Optional tag filter (URL-encoded automatically).
/// - `date_after_year` — Optional lower-bound year (inclusive).
/// - `date_before_year` — Optional upper-bound year (inclusive).
pub async fn list_quotes(
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after_year: Option<&str>,
date_before_year: Option<&str>,
) -> Result<ListResponse, ApiError> {
let mut url = format!("/api/quotes?page={page}");
if let Some(a) = author {
@ -64,6 +68,12 @@ pub async fn list_quotes(
if let Some(t) = tag {
url.push_str(&format!("&tag={}", js_sys::encode_uri_component(t)));
}
if let Some(y) = date_after_year {
url.push_str(&format!("&date_after_year={y}"));
}
if let Some(y) = date_before_year {
url.push_str(&format!("&date_before_year={y}"));
}
fetch_json(&url).await
}

@ -49,7 +49,7 @@ pub fn author_page(props: &AuthorPageProps) -> Html {
Some(tag_val.clone())
};
spawn_local(async move {
match api::list_quotes(page_val, Some(&name), tag.as_deref()).await {
match api::list_quotes(page_val, Some(&name), tag.as_deref(), None, None).await {
Ok(resp) => {
quotes.set(resp.quotes);
total_pages.set(resp.total_pages.max(1));

@ -1,4 +1,4 @@
//! Browse page — paginated quote list with author and tag filter controls.
//! Browse page — paginated quote list with author, tag, and date filter controls.
use crate::api;
use crate::components::error::ErrorDisplay;
@ -11,31 +11,46 @@ use yew::prelude::*;
/// 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.
/// Displays a paginated list of quotes. Supports filtering by author name,
/// tag, and an optional date range (year granularity). Fetches from the API
/// whenever page, author, tag, or date state changes.
#[function_component(BrowsePage)]
pub fn browse_page() -> Html {
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 date_after_year: UseStateHandle<String> = use_state(String::new);
let date_before_year: UseStateHandle<String> = 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
// Fetch quotes whenever page, author, tag, or date bounds change
{
let page = page.clone();
let total_pages = total_pages.clone();
let author_filter = author_filter.clone();
let tag_filter = tag_filter.clone();
let date_after_year = date_after_year.clone();
let date_before_year = date_before_year.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 |_| {
let date_after_year_val = (*date_after_year).clone();
let date_before_year_val = (*date_before_year).clone();
use_effect_with(
(
page_val,
author_val.clone(),
tag_val.clone(),
date_after_year_val.clone(),
date_before_year_val.clone(),
),
move |_| {
loading.set(true);
error.set(None);
let author = if author_val.is_empty() {
@ -48,8 +63,26 @@ pub fn browse_page() -> Html {
} else {
Some(tag_val.clone())
};
let after_year = if date_after_year_val.is_empty() {
None
} else {
Some(date_after_year_val.clone())
};
let before_year = if date_before_year_val.is_empty() {
None
} else {
Some(date_before_year_val.clone())
};
spawn_local(async move {
match api::list_quotes(page_val, author.as_deref(), tag.as_deref()).await {
match api::list_quotes(
page_val,
author.as_deref(),
tag.as_deref(),
after_year.as_deref(),
before_year.as_deref(),
)
.await
{
Ok(resp) => {
quotes.set(resp.quotes);
total_pages.set(resp.total_pages.max(1));
@ -61,7 +94,8 @@ pub fn browse_page() -> Html {
}
}
});
});
},
);
}
let on_page = {
@ -89,6 +123,26 @@ pub fn browse_page() -> Html {
})
};
let on_date_after_year_input = {
let date_after_year = date_after_year.clone();
let page = page.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
date_after_year.set(input.value());
page.set(1);
})
};
let on_date_before_year_input = {
let date_before_year = date_before_year.clone();
let page = page.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
date_before_year.set(input.value());
page.set(1);
})
};
html! {
<div class="page-browse">
<h1 class="page-browse__title">{ "Browse Quotes" }</h1>
@ -108,6 +162,30 @@ pub fn browse_page() -> Html {
value={(*tag_filter).clone()}
oninput={on_tag_input}
/>
<div class="browse-filter__group">
<label>{ "From year" }</label>
<input
class="page-browse__filter-input"
type="number"
min="0"
max="9999"
placeholder="e.g. 1900"
value={(*date_after_year).clone()}
oninput={on_date_after_year_input}
/>
</div>
<div class="browse-filter__group">
<label>{ "To year" }</label>
<input
class="page-browse__filter-input"
type="number"
min="0"
max="9999"
placeholder="e.g. 2025"
value={(*date_before_year).clone()}
oninput={on_date_before_year_input}
/>
</div>
</div>
if *loading {

Loading…
Cancel
Save