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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user