feat(home): add a "Recommended for you" shelf

Surface the content-based /me/recommendations feed as a horizontal shelf on
the homepage, below the Continue-reading shelf. Fetched after the catalogue
(same non-blocking pattern), shown only when signed in and the feed is
non-empty; reuses MangaCard in a fixed-width scroller. Component unit test +
e2e (signed-in shows cards, anonymous hides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-05 17:46:33 +02:00
parent d6a109df2d
commit 19db66a845
9 changed files with 185 additions and 5 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.108.0" version = "0.109.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.108.0" version = "0.109.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -0,0 +1,59 @@
import { test, expect, type Page } from './fixtures';
// Homepage "Recommended for you" shelf: visible with cards when signed in and
// the feed is non-empty; absent for anonymous visitors (401 → empty).
const emptyMangas = { items: [], page: { limit: 50, offset: 0, total: 0 } };
function manga(id: string, title: string) {
return {
id, title, 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: [], genres: []
};
}
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
);
await page.route('**/api/v1/genres*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
await page.route('**/api/v1/mangas*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptyMangas) })
);
// Keep the Continue-reading shelf out of the way.
await page.route('**/api/v1/me/read-progress*', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
}
test('shows the recommendations shelf with cards when signed in', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', is_admin: false } }) })
);
await page.route('**/api/v1/me/recommendations*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [manga('m1', 'Berserk'), manga('m2', 'Vinland')] }) })
);
await page.goto('/');
await expect(page.getByTestId('recommendations-shelf')).toBeVisible();
await expect(page.getByTestId('rec-card-m1')).toBeVisible();
await expect(page.getByTestId('rec-card-m2')).toBeVisible();
});
test('hides the recommendations shelf for anonymous visitors', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.route('**/api/v1/me/recommendations*', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Mangas' })).toBeVisible();
await expect(page.getByTestId('recommendations-shelf')).toHaveCount(0);
});

View File

@@ -1,12 +1,12 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.108.0", "version": "0.109.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.108.0", "version": "0.109.0",
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^1.16.0", "@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0", "@playwright/test": "^1.48.0",

View File

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

View File

@@ -0,0 +1,17 @@
import { ApiError, request } from './client';
import type { MangaCard } from './mangas';
/**
* GET /v1/me/recommendations — content-based "Recommended for you" feed.
* Returns an empty list for guests (401) so callers can render nothing
* without special-casing auth.
*/
export async function listMyRecommendations(): Promise<MangaCard[]> {
try {
const r = await request<{ items: MangaCard[] }>('/v1/me/recommendations');
return r.items;
} catch (e) {
if (e instanceof ApiError && e.status === 401) return [];
throw e;
}
}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte';
import type { MangaCard as MangaCardData } from '$lib/api/mangas';
// Horizontal "Recommended for you" shelf for the homepage, fed by the
// content-based /me/recommendations feed. Reuses the catalogue MangaCard.
// Rendered only when there are recommendations (the homepage owns that
// gate), so this has no empty state.
let {
mangas,
testid = 'recommendations-shelf'
}: {
mangas: MangaCardData[];
testid?: string;
} = $props();
</script>
<section class="shelf" aria-label="Recommended for you" data-testid={testid}>
<h2 class="shelf-title">Recommended for you</h2>
<ul class="rec-track" data-testid="{testid}-list">
{#each mangas as m (m.id)}
<MangaCard manga={m} authors={m.authors} genres={m.genres} testid="rec-card-{m.id}" />
{/each}
</ul>
</section>
<style>
.shelf {
margin: 0 0 var(--space-5);
}
.shelf-title {
font-size: var(--font-md);
font-weight: var(--weight-semibold);
margin: 0 0 var(--space-2);
}
.rec-track {
list-style: none;
display: flex;
gap: var(--space-4);
padding: 0 0 var(--space-2);
margin: 0;
overflow-x: auto;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
}
/* MangaCard renders an <li class="manga-card">; in this horizontal
scroller give each a fixed width instead of the grid's flexible track. */
.rec-track > :global(.manga-card) {
flex: 0 0 auto;
width: 150px;
scroll-snap-align: start;
}
</style>

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import RecommendationShelf from './RecommendationShelf.svelte';
import type { MangaCard } from '$lib/api/mangas';
afterEach(() => cleanup());
function manga(id: string, title: string): MangaCard {
return {
id,
title,
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: [],
genres: []
};
}
describe('RecommendationShelf', () => {
it('renders a card per recommendation under a heading', () => {
render(RecommendationShelf, {
props: { mangas: [manga('m1', 'Berserk'), manga('m2', 'Vinland')] }
});
expect(screen.getByRole('heading', { name: /recommended for you/i })).toBeTruthy();
expect(screen.getByTestId('rec-card-m1')).toBeTruthy();
expect(screen.getByTestId('rec-card-m2')).toBeTruthy();
expect(screen.getByText('Berserk')).toBeTruthy();
expect(screen.getByText('Vinland')).toBeTruthy();
});
});

View File

@@ -27,8 +27,10 @@
type ReadProgressSummary type ReadProgressSummary
} from '$lib/api/read_progress'; } from '$lib/api/read_progress';
import { isCaughtUp } from '$lib/continueReading'; import { isCaughtUp } from '$lib/continueReading';
import { listMyRecommendations } from '$lib/api/recommendations';
import Chip from '$lib/components/Chip.svelte'; import Chip from '$lib/components/Chip.svelte';
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte'; import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
import MangaCard from '$lib/components/MangaCard.svelte'; import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte'; import Pager from '$lib/components/Pager.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte';
@@ -44,6 +46,7 @@
let mangas: MangaCardData[] = $state([]); let mangas: MangaCardData[] = $state([]);
let continueEntries = $state<ReadProgressSummary[]>([]); let continueEntries = $state<ReadProgressSummary[]>([]);
let recommendations = $state<MangaCardData[]>([]);
let search = $state(''); let search = $state('');
let sort = $state<MangaSort>(DEFAULT_SORT); let sort = $state<MangaSort>(DEFAULT_SORT);
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT)); let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
@@ -320,6 +323,13 @@
// Never let a history hiccup break the catalogue — leave the // Never let a history hiccup break the catalogue — leave the
// shelf hidden. // shelf hidden.
} }
try {
// Personal "Recommended for you" feed (empty for guests / no
// taste signals yet → shelf hidden).
recommendations = await listMyRecommendations();
} catch {
// Non-critical — leave the shelf hidden on failure.
}
}); });
// Track viewport in a separate $effect so the listener cleans up on // Track viewport in a separate $effect so the listener cleans up on
@@ -478,6 +488,10 @@
<ContinueReadingShelf entries={continueEntries} /> <ContinueReadingShelf entries={continueEntries} />
{/if} {/if}
{#if recommendations.length > 0}
<RecommendationShelf mangas={recommendations} />
{/if}
<form <form
onsubmit={onSubmit} onsubmit={onSubmit}
action="javascript:void(0)" action="javascript:void(0)"