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:
@@ -68,6 +68,7 @@ async function mockDetail(
|
||||
page: number;
|
||||
} | null;
|
||||
authed?: boolean;
|
||||
similar?: Array<Record<string, unknown>>;
|
||||
} = {}
|
||||
) {
|
||||
const authed = opts.authed ?? false;
|
||||
@@ -128,6 +129,13 @@ async function mockDetail(
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: opts.similar ?? [] })
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: opts.readProgress ? 200 : 404,
|
||||
@@ -241,4 +249,39 @@ test.describe('mobile manga detail', () => {
|
||||
await expect(page.getByTestId('manga-title')).toBeVisible();
|
||||
await expect(page.getByTestId('bookmark-toggle')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the Similar section with recommended cards when present', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page, {
|
||||
similar: [
|
||||
{
|
||||
id: 'b2222222-2222-2222-2222-222222222222',
|
||||
title: 'Vinland Saga',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [{ id: 'au2', name: 'Makoto Yukimura' }],
|
||||
genres: []
|
||||
}
|
||||
]
|
||||
});
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
const section = page.getByTestId('similar-section');
|
||||
await expect(section).toBeVisible();
|
||||
await expect(section.getByText('Vinland Saga')).toBeVisible();
|
||||
});
|
||||
|
||||
test('omits the Similar section when there are no recommendations', async ({ page }) => {
|
||||
await mockDetail(page, { similar: [] });
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
// The chapter list is the anchor that the page has rendered.
|
||||
await expect(page.getByTestId('manga-title')).toBeVisible();
|
||||
await expect(page.getByTestId('similar-section')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user