feat(mangas): per-field sort options + direction toggle on the catalog (0.88.0)
Sort the manga catalog by created / updated / title / author with an independent asc/desc direction control. An omitted `order` defaults per field (dates newest-first, text A→Z), matching the UI, so a bare `?sort=<field>` means the same thing in the browser and over the API; `sort=recent` remains a back-compat alias for `created`. - Backend: SortField/SortOrder parsed with validation (structured 422 on bad input), per-field default_order, NULLS LAST only on the nullable author key, and migration 0033 indexing mangas(updated_at DESC, id) to back the default sort and its id tie-break. - Frontend: catalog sort field + direction (SegmentedControl) on desktop and a mobile bottom sheet; pure helpers in $lib/mangaSort; keyboard-accessible direction control; visible Direction labels and a sheet "Done" button. - Tests: backend integration coverage (defaults, alias, invalid input, ascending-id tie-break), frontend unit + e2e. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,14 +51,58 @@ pub struct ListParams {
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Sort field: `created`, `updated` (default), `title`, or `author`.
|
||||
/// Also accepts the legacy `recent` alias (== `created`). Anything
|
||||
/// else → 422.
|
||||
#[serde(default)]
|
||||
pub sort: repo::manga::ListSort,
|
||||
pub sort: Option<String>,
|
||||
/// `asc` or `desc`. Omitted → the field's natural default
|
||||
/// (dates `desc`, text `asc`). Anything else → 422.
|
||||
#[serde(default)]
|
||||
pub order: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
/// Parse the `sort` wire value into a [`repo::manga::SortField`]. Validation
|
||||
/// and the legacy `recent` alias live here (not in serde) so unknown values
|
||||
/// return the structured 422 envelope rather than axum's plain-text
|
||||
/// `QueryRejection`, matching the rest of this handler's filters.
|
||||
fn parse_sort(raw: Option<&str>) -> AppResult<repo::manga::SortField> {
|
||||
use repo::manga::SortField;
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") | Some("updated") => Ok(SortField::Updated),
|
||||
// `recent` was the pre-0.88 name for "newest created first"; keep it
|
||||
// as an alias so existing bots/scripts don't break.
|
||||
Some("created") | Some("recent") => Ok(SortField::Created),
|
||||
Some("title") => Ok(SortField::Title),
|
||||
Some("author") => Ok(SortField::Author),
|
||||
Some(other) => Err(AppError::ValidationFailed {
|
||||
message: format!(
|
||||
"sort must be one of created, updated, title, author (got {other:?})"
|
||||
),
|
||||
details: json!({ "sort": "invalid" }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `order` wire value. `None` means the client omitted it, so the
|
||||
/// caller applies the field's natural default. An explicit unknown value → 422.
|
||||
fn parse_order(raw: Option<&str>) -> AppResult<Option<repo::manga::SortOrder>> {
|
||||
use repo::manga::SortOrder;
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some("asc") => Ok(Some(SortOrder::Asc)),
|
||||
Some("desc") => Ok(Some(SortOrder::Desc)),
|
||||
Some(other) => Err(AppError::ValidationFailed {
|
||||
message: format!("order must be 'asc' or 'desc' (got {other:?})"),
|
||||
details: json!({ "order": "invalid" }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid_csv(field: &str, raw: Option<&str>) -> AppResult<Vec<Uuid>> {
|
||||
let Some(raw) = raw else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
@@ -94,6 +138,10 @@ async fn list(
|
||||
Some(s.to_string())
|
||||
}
|
||||
};
|
||||
let sort = parse_sort(params.sort.as_deref())?;
|
||||
// Omitted `order` falls back to the field's natural direction, so a bare
|
||||
// `?sort=title` reads A→Z just like the UI shows it.
|
||||
let order = parse_order(params.order.as_deref())?.unwrap_or_else(|| sort.default_order());
|
||||
let q = repo::manga::ListQuery {
|
||||
search: params.search.filter(|s| !s.trim().is_empty()),
|
||||
status,
|
||||
@@ -104,7 +152,8 @@ async fn list(
|
||||
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
|
||||
limit,
|
||||
offset,
|
||||
sort: params.sort,
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
|
||||
Reference in New Issue
Block a user