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:
17
frontend/src/lib/api/recommendations.ts
Normal file
17
frontend/src/lib/api/recommendations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
frontend/src/lib/components/RecommendationShelf.svelte
Normal file
56
frontend/src/lib/components/RecommendationShelf.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user