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

@@ -1,4 +1,4 @@
import { getManga } from '$lib/api/mangas';
import { getManga, getSimilarMangas, type MangaCard } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
@@ -7,17 +7,21 @@ import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, chapters, bookmarks, readProgress] = await Promise.all([
const [manga, chapters, bookmarks, readProgress, similar] = await Promise.all([
getManga(params.id),
listChapters(params.id),
listMyBookmarksOrEmpty(),
// Null when guest or never-read — page handles both cases.
getMyReadProgressForManga(params.id)
getMyReadProgressForManga(params.id),
// Recommendations are non-critical: a failure here must not break
// the detail page, so fall back to an empty list.
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
]);
return {
manga,
chapters: chapters.items,
bookmarks: bookmarks.items,
readProgress
readProgress,
similar
};
};