feat(mangas): add tag-similarity "Similar" section on manga detail

Add GET /v1/mangas/:id/similar returning the top 5 mangas ranked by
Jaccard tag overlap (shared / union), excluding self, as MangaCard items
in a plain { items } object. Surface them in a hidden-when-empty
"Similar" section on the manga detail page, reusing MangaCard.

Backend: new repo::manga::list_similar with a manga_tags self-join; a
shared cards_from_rows helper (also used by list_cards) that loads
authors+genres concurrently; single-sourced column list via MANGA_COLS +
manga_cols(alias) to replace the fragile SELECT_COLS string-splitting.

Frontend: getSimilarMangas client fn (guards a malformed body), loader
fetch with non-critical .catch fallback, and the catalog grid hoisted to
a shared global .manga-grid (lib/styles/tokens.css), de-duplicating the
per-route copies.

Bump version 0.62.0 -> 0.63.0 (backend + frontend in lockstep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 15:48:15 +02:00
parent 6c901e64c9
commit 33f684887d
15 changed files with 423 additions and 54 deletions

View File

@@ -21,6 +21,7 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one).patch(update))
.route("/mangas/:id/similar", get(list_similar))
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
.route("/mangas/:id/tags", post(attach_tag))
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
@@ -108,6 +109,28 @@ async fn get_one(
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
/// How many similar mangas the recommendation section shows.
const SIMILAR_LIMIT: i64 = 5;
/// `GET /api/v1/mangas/:id/similar` — top-`SIMILAR_LIMIT` mangas ranked by
/// tag overlap with `:id`, as cards. Read-only and unauthenticated, like
/// `get_one`/`list`. Returns a plain `{ items: [...] }` object (not the
/// paginated envelope) since this is a fixed top-N, not a collection.
///
/// The `exists` check is load-bearing: `list_similar` returns an empty list
/// for both an untagged manga and a nonexistent id, so without it an unknown
/// id would 200 with `[]` instead of 404.
async fn list_similar(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
let items = repo::manga::list_similar(&state.db, id, SIMILAR_LIMIT).await?;
Ok(Json(json!({ "items": items })))
}
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
///
/// - `metadata` (required): JSON body matching `NewManga` — title, optional