diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a7ea865..dee5bbe 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.108.0" +version = "0.109.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 40ec794..08360b1 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.108.0" +version = "0.109.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/recommendations-shelf.spec.ts b/frontend/e2e/recommendations-shelf.spec.ts new file mode 100644 index 0000000..4b4ece5 --- /dev/null +++ b/frontend/e2e/recommendations-shelf.spec.ts @@ -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); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae57c70..53826f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.108.0", + "version": "0.109.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.108.0", + "version": "0.109.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 75b7e70..26f61ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.108.0", + "version": "0.109.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/recommendations.ts b/frontend/src/lib/api/recommendations.ts new file mode 100644 index 0000000..2db5986 --- /dev/null +++ b/frontend/src/lib/api/recommendations.ts @@ -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 { + 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; + } +} diff --git a/frontend/src/lib/components/RecommendationShelf.svelte b/frontend/src/lib/components/RecommendationShelf.svelte new file mode 100644 index 0000000..d1ecb14 --- /dev/null +++ b/frontend/src/lib/components/RecommendationShelf.svelte @@ -0,0 +1,56 @@ + + +
+

Recommended for you

+ +
+ + diff --git a/frontend/src/lib/components/RecommendationShelf.svelte.test.ts b/frontend/src/lib/components/RecommendationShelf.svelte.test.ts new file mode 100644 index 0000000..c0ba56a --- /dev/null +++ b/frontend/src/lib/components/RecommendationShelf.svelte.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 8978ce5..32712fd 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -27,8 +27,10 @@ type ReadProgressSummary } from '$lib/api/read_progress'; import { isCaughtUp } from '$lib/continueReading'; + import { listMyRecommendations } from '$lib/api/recommendations'; import Chip from '$lib/components/Chip.svelte'; import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte'; + import RecommendationShelf from '$lib/components/RecommendationShelf.svelte'; import MangaCard from '$lib/components/MangaCard.svelte'; import Pager from '$lib/components/Pager.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte'; @@ -44,6 +46,7 @@ let mangas: MangaCardData[] = $state([]); let continueEntries = $state([]); + let recommendations = $state([]); let search = $state(''); let sort = $state(DEFAULT_SORT); let order = $state(defaultOrderFor(DEFAULT_SORT)); @@ -320,6 +323,13 @@ // Never let a history hiccup break the catalogue — leave the // 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 @@ -478,6 +488,10 @@ {/if} +{#if recommendations.length > 0} + +{/if} +