diff --git a/.gitignore b/.gitignore index a69147b..977232d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,12 @@ /frontend/test-results /frontend/playwright-report -# Local storage volume (manga files) +# Local storage volume (manga files). `/data` is the root path the +# compose volume mounts at; `/backend/data` is where the dev backend +# writes when STORAGE_DIR isn't overridden (default `./data/storage` +# relative to backend cwd). /data +/backend/data # Env .env diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0e02537..0817c98 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.11.0" +version = "0.11.1" edition = "2021" [lib] diff --git a/backend/src/domain/bookmark.rs b/backend/src/domain/bookmark.rs index 57ac6ed..93d8474 100644 --- a/backend/src/domain/bookmark.rs +++ b/backend/src/domain/bookmark.rs @@ -13,16 +13,23 @@ pub struct Bookmark { pub created_at: DateTime, } -/// `Bookmark` enriched with the chapter's reader-facing number, so the -/// frontend can build `/manga/{id}/chapter/{number}` links without an -/// extra round-trip per bookmark. Populated by a LEFT JOIN; `null` for -/// manga-level bookmarks or for chapter bookmarks whose chapter has -/// since been deleted (`chapter_id` is `ON DELETE SET NULL`). +/// `Bookmark` enriched with the parent manga's title + cover and the +/// chapter's reader-facing number, so the /bookmarks page can render a +/// proper card (title, cover, link target) without N+1 round-trips. +/// Populated by JOINs in `repo::bookmark::list_for_user`: +/// - `manga_title` / `manga_cover_image_path` come from `mangas` via an +/// INNER JOIN (safe: `bookmarks.manga_id` is `ON DELETE CASCADE`, so a +/// bookmark cannot outlive its manga). +/// - `chapter_number` comes from `chapters` via a LEFT JOIN; `null` for +/// manga-level bookmarks or for chapter bookmarks whose chapter has +/// since been deleted (`bookmarks.chapter_id` is `ON DELETE SET NULL`). #[derive(Debug, Clone, Serialize, FromRow)] pub struct BookmarkSummary { pub id: Uuid, pub user_id: Uuid, pub manga_id: Uuid, + pub manga_title: String, + pub manga_cover_image_path: Option, pub chapter_id: Option, pub chapter_number: Option, pub page: Option, diff --git a/backend/src/repo/bookmark.rs b/backend/src/repo/bookmark.rs index ab79ea9..0ec707d 100644 --- a/backend/src/repo/bookmark.rs +++ b/backend/src/repo/bookmark.rs @@ -53,11 +53,14 @@ pub async fn list_for_user( b.id, b.user_id, b.manga_id, + m.title AS manga_title, + m.cover_image_path AS manga_cover_image_path, b.chapter_id, c.number AS chapter_number, b.page, b.created_at FROM bookmarks b + INNER JOIN mangas m ON m.id = b.manga_id LEFT JOIN chapters c ON c.id = b.chapter_id WHERE b.user_id = $1 ORDER BY b.created_at DESC diff --git a/backend/tests/api_bookmarks.rs b/backend/tests/api_bookmarks.rs index b57605b..8fb04c3 100644 --- a/backend/tests/api_bookmarks.rs +++ b/backend/tests/api_bookmarks.rs @@ -402,6 +402,11 @@ async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) { .expect("chapter-level bookmark present"); assert_eq!(chapter_bookmark["chapter_number"], 7); assert_eq!(chapter_bookmark["page"], 4); + // Manga title is JOINed in too so the /bookmarks page can render + // a real card instead of "Manga bookmark". + assert_eq!(chapter_bookmark["manga_title"], "Berserk"); + // No cover was uploaded for the seeded manga — null is correct. + assert!(chapter_bookmark["manga_cover_image_path"].is_null()); let manga_bookmark = items .iter() @@ -411,6 +416,7 @@ async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) { manga_bookmark["chapter_number"].is_null(), "manga-level bookmark must have a null chapter_number" ); + assert_eq!(manga_bookmark["manga_title"], "Berserk"); } #[sqlx::test(migrations = "./migrations")] diff --git a/frontend/e2e/bookmarks.spec.ts b/frontend/e2e/bookmarks.spec.ts index 98573d4..c81c29f 100644 --- a/frontend/e2e/bookmarks.spec.ts +++ b/frontend/e2e/bookmarks.spec.ts @@ -23,9 +23,18 @@ const bookmarkFixture = { page: null, created_at: '2026-01-01T00:00:00Z' }; +// /me/bookmarks responses are enriched (`BookmarkSummary` on the wire), +// while POST /bookmarks returns the bare `Bookmark`. The mock layer +// returns the bare fixture on POST and the enriched one in the list. +const enrichedBookmarkFixture = { + ...bookmarkFixture, + manga_title: 'Berserk', + manga_cover_image_path: null, + chapter_number: null +}; async function setupAuthenticatedBookmarkFlow(page: Page) { - let bookmarks: typeof bookmarkFixture[] = []; + let bookmarks: typeof enrichedBookmarkFixture[] = []; await page.route('**/api/v1/auth/me', (route) => route.fulfill({ @@ -67,7 +76,11 @@ async function setupAuthenticatedBookmarkFlow(page: Page) { ); await page.route('**/api/v1/bookmarks', (route) => { if (route.request().method() === 'POST') { - bookmarks = [bookmarkFixture, ...bookmarks]; + // List endpoint is enriched (BookmarkSummary), POST returns + // the bare Bookmark — but the in-memory list we surface to + // /me/bookmarks uses the enriched fixture so the rendered + // card has the manga title. + bookmarks = [enrichedBookmarkFixture, ...bookmarks]; route.fulfill({ status: 201, contentType: 'application/json', @@ -101,7 +114,10 @@ test('authed user toggles a manga bookmark and sees it in /bookmarks', async ({ // The /bookmarks list reflects it. await page.goto('/bookmarks'); - await expect(page.getByTestId('bookmark-list')).toContainText('Manga bookmark'); + // The /bookmarks page now renders a real card with the manga + // title — the fixture title is "Berserk" (see mangaFixture above). + await expect(page.getByTestId('bookmark-title')).toContainText('Berserk'); + await expect(page.getByTestId('bookmark-list')).toContainText('Whole manga'); // Toggle off from the manga page. await page.goto(`/manga/${mangaId}`); diff --git a/frontend/package.json b/frontend/package.json index 72a468c..f84b172 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.11.0", + "version": "0.11.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/bookmarks.ts b/frontend/src/lib/api/bookmarks.ts index dd869f5..88dce77 100644 --- a/frontend/src/lib/api/bookmarks.ts +++ b/frontend/src/lib/api/bookmarks.ts @@ -8,9 +8,17 @@ export type Bookmark = { /** * Reader-facing chapter number, populated by the backend's LEFT * JOIN when listing. `null` for manga-level bookmarks and for - * chapter bookmarks whose chapter has been deleted. + * chapter bookmarks whose chapter has been deleted. Absent on the + * bare POST response (which returns `Bookmark`, not enriched). */ chapter_number?: number | null; + /** + * Parent manga title + cover, JOINed in on the list endpoint so + * the /bookmarks page can render a real card instead of just a + * date. Absent on the POST/DELETE responses. + */ + manga_title?: string; + manga_cover_image_path?: string | null; page: number | null; created_at: string; }; diff --git a/frontend/src/routes/bookmarks/+page.svelte b/frontend/src/routes/bookmarks/+page.svelte index c0b2612..cedc328 100644 --- a/frontend/src/routes/bookmarks/+page.svelte +++ b/frontend/src/routes/bookmarks/+page.svelte @@ -1,4 +1,6 @@