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

@@ -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);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.62.0",
"version": "0.63.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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({

View File

@@ -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;

View File

@@ -303,3 +303,24 @@ img {
animation-duration: 0ms !important;
}
}
/* Shared grid for manga cover cards — used by the catalog, author and
collection pages, and the similar-mangas section. Single-sourced here
so the responsive layout can't drift between pages. Four covers per
row on phones is a deliberate user preference; `minmax(0, 1fr)` is
load-bearing so the grid never overflows the viewport. */
.manga-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
@media (max-width: 640px) {
.manga-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
}
}

View File

@@ -870,14 +870,8 @@
margin: var(--space-2) 0;
}
.manga-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
/* `.manga-grid` is a shared global (see lib/styles/tokens.css), including
its four-per-row phone layout. */
/* Mobile-only helpers. Defaults flip below the 640px breakpoint
so the same chrome doesn't render twice. */
@@ -899,15 +893,5 @@
the 28rem cap is desktop-only. */
max-width: none;
}
.manga-grid {
/* Four covers per row on phones (per user preference) at a
tight gap. `minmax(0, 1fr)` is load-bearing — without it
the implicit `minmax(auto, 1fr)` lets grid cells grow to
their intrinsic min-content width and push the whole
grid past the viewport edge. */
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
}
}
</style>

View File

@@ -109,12 +109,5 @@
color: var(--text-muted);
}
.manga-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
/* `.manga-grid` is a shared global (see lib/styles/tokens.css). */
</style>

View File

@@ -314,14 +314,7 @@
color: var(--text-muted);
}
.manga-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
/* `.manga-grid` is a shared global (see lib/styles/tokens.css). */
.section-heading {
margin: var(--space-5) 0 var(--space-3);

View File

@@ -16,6 +16,7 @@
import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import Sheet from '$lib/components/Sheet.svelte';
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus';
@@ -62,6 +63,9 @@
const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres);
/** Up to 5 mangas with the most similar tags; empty hides the section. */
const similar = $derived(data.similar);
// svelte-ignore state_referenced_locally
let tags = $state<TagRef[]>([...manga.tags]);
// svelte-ignore state_referenced_locally
@@ -605,6 +609,17 @@
{/if}
</section>
{#if similar.length > 0}
<section aria-label="similar mangas" class="similar" data-testid="similar-section">
<h2>Similar</h2>
<ul class="manga-grid" data-testid="similar-list">
{#each similar as m (m.id)}
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
{/each}
</ul>
</section>
{/if}
<Sheet
open={overflowOpen}
title="Actions"
@@ -688,6 +703,12 @@
}
}
.similar {
margin-top: var(--space-6);
}
/* `.manga-grid` is a shared global (see lib/styles/tokens.css). */
.cover {
width: 100%;
height: auto;

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
};
};