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:
@@ -7,7 +7,8 @@ import {
|
||||
updateMangaCover,
|
||||
deleteMangaCover,
|
||||
attachTag,
|
||||
detachTag
|
||||
detachTag,
|
||||
getSimilarMangas
|
||||
} from './mangas';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -251,6 +252,21 @@ describe('mangas api client', () => {
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('getSimilarMangas hits /v1/mangas/:id/similar and unwraps items', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [cardFixture({ id: 's1' }), cardFixture({ id: 's2' })] })
|
||||
);
|
||||
const items = await getSimilarMangas('b1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/mangas\/b1\/similar$/);
|
||||
expect(items.map((m) => m.id)).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('getSimilarMangas returns an empty array when there are no matches', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ items: [] }));
|
||||
expect(await getSimilarMangas('b1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('getManga throws ApiError carrying the envelope code on non-2xx', async () => {
|
||||
fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found'));
|
||||
await expect(getManga('missing')).rejects.toMatchObject({
|
||||
|
||||
@@ -60,6 +60,20 @@ export async function getManga(id: string): Promise<MangaDetail> {
|
||||
return request<MangaDetail>(`/v1/mangas/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/mangas/:id/similar — up to 5 mangas ranked by tag overlap with
|
||||
* `id`. Returns a plain `{ items }` object (a fixed top-N, not a paginated
|
||||
* collection), so we unwrap to the card array the page wants.
|
||||
*/
|
||||
export async function getSimilarMangas(id: string): Promise<MangaCard[]> {
|
||||
const res = await request<{ items: MangaCard[] }>(
|
||||
`/v1/mangas/${encodeURIComponent(id)}/similar`
|
||||
);
|
||||
// Defensive: a malformed 200 body (items omitted) must still yield an
|
||||
// array so the page's `similar.length` guard can't throw.
|
||||
return res.items ?? [];
|
||||
}
|
||||
|
||||
export type NewManga = {
|
||||
title: string;
|
||||
status?: MangaStatus;
|
||||
|
||||
Reference in New Issue
Block a user