fix(mangas): address review on sort feature — alias, index, validation, a11y

Backend
- sort=recent stays a back-compat alias for created (parse_sort); invalid
  sort/order now return the structured 422 envelope instead of plain-text 400
- per-field default direction on omitted order (dates desc, text asc), matching
  the frontend so a bare ?sort=<field> reads the same in UI and API
- migration 0033: index mangas(updated_at DESC, id) backing the default sort
  and its id tie-break; NULLS LAST now applied only to the nullable author key
- tests: recent alias, per-field default (title+author), invalid-value 422,
  and a tie-break test that pins ordering by ascending id (mutation-verified)

Frontend
- pure sort helpers extracted to $lib/mangaSort with unit tests; coerceSort
  honors the recent alias so pasted/legacy URLs resolve to the same field
- SegmentedControl: roving tabindex + arrow/Home/End keyboard nav, anchored on
  focus so rapid keypresses don't stick
- UX: visible "Direction" labels (desktop + mobile), mobile sort-sheet section
  headings and a "Done" button; README sort/order contract updated

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 07:15:33 +02:00
parent 1079a0151a
commit 78edea4277
11 changed files with 650 additions and 73 deletions

View File

@@ -51,16 +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::SortField,
pub sort: Option<String>,
/// `asc` or `desc`. Omitted → the field's natural default
/// (dates `desc`, text `asc`). Anything else → 422.
#[serde(default)]
pub order: repo::manga::SortOrder,
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();
@@ -96,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,
@@ -106,8 +152,8 @@ async fn list(
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
limit,
offset,
sort: params.sort,
order: params.order,
sort,
order,
};
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))