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