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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.62.0"
|
||||
version = "0.63.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.62.0"
|
||||
version = "0.63.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/mangas", get(list).post(create))
|
||||
.route("/mangas/:id", get(get_one).patch(update))
|
||||
.route("/mangas/:id/similar", get(list_similar))
|
||||
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
|
||||
.route("/mangas/:id/tags", post(attach_tag))
|
||||
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
|
||||
@@ -108,6 +109,28 @@ async fn get_one(
|
||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||
}
|
||||
|
||||
/// How many similar mangas the recommendation section shows.
|
||||
const SIMILAR_LIMIT: i64 = 5;
|
||||
|
||||
/// `GET /api/v1/mangas/:id/similar` — top-`SIMILAR_LIMIT` mangas ranked by
|
||||
/// tag overlap with `:id`, as cards. Read-only and unauthenticated, like
|
||||
/// `get_one`/`list`. Returns a plain `{ items: [...] }` object (not the
|
||||
/// paginated envelope) since this is a fixed top-N, not a collection.
|
||||
///
|
||||
/// The `exists` check is load-bearing: `list_similar` returns an empty list
|
||||
/// for both an untagged manga and a nonexistent id, so without it an unknown
|
||||
/// id would 200 with `[]` instead of 404.
|
||||
async fn list_similar(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let items = repo::manga::list_similar(&state.db, id, SIMILAR_LIMIT).await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
|
||||
///
|
||||
/// - `metadata` (required): JSON body matching `NewManga` — title, optional
|
||||
|
||||
@@ -40,8 +40,34 @@ pub struct ListQuery {
|
||||
pub sort: ListSort,
|
||||
}
|
||||
|
||||
const SELECT_COLS: &str =
|
||||
"id, title, status, alt_titles, description, cover_image_path, created_at, updated_at";
|
||||
/// Single source of truth for the `mangas` columns that hydrate a [`Manga`],
|
||||
/// so the plain and join-aliased select lists stay in lockstep with the
|
||||
/// struct's `FromRow`.
|
||||
const MANGA_COLS: [&str; 8] = [
|
||||
"id",
|
||||
"title",
|
||||
"status",
|
||||
"alt_titles",
|
||||
"description",
|
||||
"cover_image_path",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
];
|
||||
|
||||
/// `MANGA_COLS` rendered as a select list. A non-empty `alias` qualifies each
|
||||
/// column (`"m"` → `m.id, m.title, …`) for queries that join other tables;
|
||||
/// an empty alias yields the bare names used by single-table queries.
|
||||
fn manga_cols(alias: &str) -> String {
|
||||
if alias.is_empty() {
|
||||
MANGA_COLS.join(", ")
|
||||
} else {
|
||||
MANGA_COLS
|
||||
.iter()
|
||||
.map(|c| format!("{alias}.{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared WHERE used by both the rows and the count queries. Filters
|
||||
/// are AND across facets: every supplied author_id (or genre, or tag)
|
||||
@@ -100,12 +126,13 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
|
||||
|
||||
let list_sql = format!(
|
||||
r#"
|
||||
SELECT {SELECT_COLS}
|
||||
SELECT {cols}
|
||||
FROM mangas
|
||||
WHERE {FILTER_WHERE}
|
||||
ORDER BY {order_by}
|
||||
LIMIT $6 OFFSET $7
|
||||
"#
|
||||
"#,
|
||||
cols = manga_cols(""),
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<_, Manga>(&list_sql)
|
||||
@@ -145,9 +172,73 @@ pub async fn list_cards(
|
||||
query: &ListQuery,
|
||||
) -> AppResult<(Vec<MangaCard>, i64)> {
|
||||
let (rows, total) = list(pool, query).await?;
|
||||
let cards = cards_from_rows(pool, rows).await?;
|
||||
Ok((cards, total))
|
||||
}
|
||||
|
||||
/// Top-`limit` mangas ranked by tag similarity to `id`, as cards.
|
||||
///
|
||||
/// Similarity is the Jaccard index of the two tag sets —
|
||||
/// `shared / (|base| + |other| - shared)` — so a heavily-tagged manga
|
||||
/// sharing many generic tags does not crowd out genuinely-close matches.
|
||||
/// (A simpler raw shared-tag count is one ORDER BY term away, but it
|
||||
/// biases toward mangas with large tag sets; see the tie-break below.)
|
||||
///
|
||||
/// The candidate set comes from the self-join on `manga_tags`, which by
|
||||
/// construction only includes mangas sharing at least one tag and never
|
||||
/// the source itself. A source with no tags — or no overlap — yields an
|
||||
/// empty result. Existence of `id` is the caller's concern (the empty
|
||||
/// result here cannot distinguish "no tags" from "no such manga").
|
||||
pub async fn list_similar(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
limit: i64,
|
||||
) -> AppResult<Vec<MangaCard>> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT {cols}
|
||||
FROM manga_tags base
|
||||
JOIN manga_tags other
|
||||
ON other.tag_id = base.tag_id
|
||||
AND other.manga_id <> base.manga_id
|
||||
JOIN mangas m ON m.id = other.manga_id
|
||||
WHERE base.manga_id = $1
|
||||
GROUP BY m.id
|
||||
ORDER BY
|
||||
count(*)::float
|
||||
/ ( (SELECT count(*) FROM manga_tags WHERE manga_id = $1)
|
||||
+ (SELECT count(*) FROM manga_tags WHERE manga_id = m.id)
|
||||
- count(*) ) DESC,
|
||||
count(*) DESC,
|
||||
m.updated_at DESC,
|
||||
lower(m.title) ASC,
|
||||
m.id
|
||||
LIMIT $2
|
||||
"#,
|
||||
// The query joins `manga_tags`, so the mangas columns need the `m.` alias.
|
||||
cols = manga_cols("m"),
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<_, Manga>(&sql)
|
||||
.bind(id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
cards_from_rows(pool, rows).await
|
||||
}
|
||||
|
||||
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
|
||||
/// authors and genres in two batched round-trips. The input order is
|
||||
/// preserved (callers rely on this to keep list/ranking order), so we
|
||||
/// iterate `rows` rather than the id-ordered `BTreeMap`s.
|
||||
async fn cards_from_rows(pool: &PgPool, rows: Vec<Manga>) -> AppResult<Vec<MangaCard>> {
|
||||
let ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
|
||||
let mut authors = repo::author::load_for_mangas(pool, &ids).await?;
|
||||
let mut genres = repo::genre::load_for_mangas(pool, &ids).await?;
|
||||
// Authors and genres are independent reads — load them concurrently.
|
||||
let (mut authors, mut genres) = tokio::try_join!(
|
||||
repo::author::load_for_mangas(pool, &ids),
|
||||
repo::genre::load_for_mangas(pool, &ids),
|
||||
)?;
|
||||
let cards = rows
|
||||
.into_iter()
|
||||
.map(|manga| MangaCard {
|
||||
@@ -156,12 +247,13 @@ pub async fn list_cards(
|
||||
manga,
|
||||
})
|
||||
.collect();
|
||||
Ok((cards, total))
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<Manga> {
|
||||
sqlx::query_as::<_, Manga>(&format!(
|
||||
"SELECT {SELECT_COLS} FROM mangas WHERE id = $1"
|
||||
"SELECT {} FROM mangas WHERE id = $1",
|
||||
manga_cols("")
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -198,8 +290,9 @@ pub async fn create<'e, E: PgExecutor<'e>>(
|
||||
r#"
|
||||
INSERT INTO mangas (title, status, description, alt_titles, uploaded_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING {SELECT_COLS}
|
||||
"#
|
||||
RETURNING {cols}
|
||||
"#,
|
||||
cols = manga_cols(""),
|
||||
))
|
||||
.bind(title)
|
||||
.bind(status)
|
||||
@@ -234,8 +327,9 @@ pub async fn update_basics(
|
||||
alt_titles = COALESCE($6, alt_titles),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING {SELECT_COLS}
|
||||
"#
|
||||
RETURNING {cols}
|
||||
"#,
|
||||
cols = manga_cols(""),
|
||||
))
|
||||
.bind(id)
|
||||
.bind(title)
|
||||
|
||||
@@ -315,3 +315,166 @@ async fn get_unknown_id_is_404_with_envelope(pool: PgPool) {
|
||||
let msg = body["error"]["message"].as_str().expect("message is string");
|
||||
assert!(!msg.is_empty(), "message should be non-empty");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /v1/mangas/:id/similar — recommendation by tag overlap (Jaccard).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Attach a tag to a manga via the public API. Asserts the call succeeds
|
||||
/// (201 created or 200 already-attached) so test setup failures surface loudly.
|
||||
async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: uuid::Uuid, name: &str) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/tags"),
|
||||
json!({ "name": name }),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK,
|
||||
"attach_tag({name}) failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
fn ids(body: &serde_json::Value) -> Vec<String> {
|
||||
body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn similar_ranks_by_jaccard(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
let a = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
|
||||
let b = common::seed_manga_via_api(&h.app, &cookie, "Perfect Match").await;
|
||||
let c = common::seed_manga_via_api(&h.app, &cookie, "Tight Subset").await;
|
||||
let d = common::seed_manga_via_api(&h.app, &cookie, "Broad Overlap").await;
|
||||
let e = common::seed_manga_via_api(&h.app, &cookie, "No Overlap").await;
|
||||
|
||||
// Source A: t1..t4
|
||||
for t in ["t1", "t2", "t3", "t4"] {
|
||||
attach_tag(&h.app, &cookie, a, t).await;
|
||||
}
|
||||
// B shares all 4 -> Jaccard 4/4 = 1.0
|
||||
for t in ["t1", "t2", "t3", "t4"] {
|
||||
attach_tag(&h.app, &cookie, b, t).await;
|
||||
}
|
||||
// C has t1,t2 -> shares 2 -> Jaccard 2/4 = 0.5
|
||||
for t in ["t1", "t2"] {
|
||||
attach_tag(&h.app, &cookie, c, t).await;
|
||||
}
|
||||
// D has t1..t8 -> shares 4 -> Jaccard 4/8 = 0.5 (tie with C; loses on shared-count tie-break)
|
||||
for t in ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"] {
|
||||
attach_tag(&h.app, &cookie, d, t).await;
|
||||
}
|
||||
// E disjoint -> excluded
|
||||
for t in ["z1", "z2"] {
|
||||
attach_tag(&h.app, &cookie, e, t).await;
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{a}/similar")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let got = ids(&body);
|
||||
|
||||
let b = b.to_string();
|
||||
let c = c.to_string();
|
||||
let d = d.to_string();
|
||||
// B (1.0) first; then C and D both 0.5 but C wins the shared-count tie-break (D shares 4 too,
|
||||
// wait — D shares 4, C shares 2). Tie-break is more-shared-first, so D precedes C.
|
||||
assert_eq!(got, vec![b, d, c], "ranked B(1.0), D(0.5,4 shared), C(0.5,2 shared)");
|
||||
// Self and the disjoint manga never appear.
|
||||
assert!(!got.contains(&a.to_string()));
|
||||
assert!(!got.contains(&e.to_string()));
|
||||
// Cards carry the enriched shape.
|
||||
assert!(body["items"][0]["authors"].is_array());
|
||||
assert!(body["items"][0]["genres"].is_array());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn similar_caps_at_five_and_excludes_self(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
|
||||
attach_tag(&h.app, &cookie, source, "shared").await;
|
||||
|
||||
for i in 0..7 {
|
||||
let m = common::seed_manga_via_api(&h.app, &cookie, &format!("Neighbor {i}")).await;
|
||||
attach_tag(&h.app, &cookie, m, "shared").await;
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let got = ids(&body);
|
||||
assert_eq!(got.len(), 5, "capped at 5");
|
||||
assert!(!got.contains(&source.to_string()), "self excluded");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn similar_empty_when_no_tags(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let source = common::seed_manga_via_api(&h.app, &cookie, "Untagged").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"], json!([]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn similar_empty_when_no_overlap(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
|
||||
let other = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
|
||||
attach_tag(&h.app, &cookie, source, "alpha").await;
|
||||
attach_tag(&h.app, &cookie, other, "beta").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["items"], json!([]));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn similar_404_for_unknown_manga(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get(&format!(
|
||||
"/api/v1/mangas/{}/similar",
|
||||
uuid::Uuid::new_v4()
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "not_found");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.62.0",
|
||||
"version": "0.63.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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