bugfix: /bookmarks renders manga title and cover

The bookmarks list was rendering "Manga bookmark <date>" with no
indication of which manga the bookmark referred to. The data is
already in the DB — the list query just wasn't pulling it.

Backend:
- BookmarkSummary gains manga_title (String) and
  manga_cover_image_path (Option<String>). Populated by an INNER JOIN
  on `mangas` in `repo::bookmark::list_for_user`. The JOIN is INNER
  because `bookmarks.manga_id` has ON DELETE CASCADE, so a bookmark
  cannot outlive its manga. Chapter LEFT JOIN unchanged.
- The existing list_me_enriches_chapter_bookmarks_with_chapter_number
  test now also asserts manga_title is populated for both chapter-
  and manga-level bookmarks, and that manga_cover_image_path is null
  when no cover was uploaded.

Frontend:
- Bookmark type carries optional manga_title and
  manga_cover_image_path (optional because POST /bookmarks returns
  the bare Bookmark, not the enriched summary).
- /bookmarks page redesigned as a grid: cover thumbnail (64×96 with
  a placeholder when no cover) on the left, then the manga title (as
  the primary link), then either "Chapter N — page M" linked to the
  reader, "(chapter removed)" for orphan chapter bookmarks, or
  "Whole manga" for manga-level bookmarks. Bookmark date moves to a
  subdued footer.
- E2E fixtures track the enriched shape returned by the list endpoint
  (vs. the bare Bookmark returned by POST). The toggle test now
  asserts the manga title appears on the bookmarks card after the
  bookmark is created.

Also: tighten .gitignore. `/data` only catches the compose volume
root; the dev backend writes to `/backend/data` (default STORAGE_DIR
is `./data/storage` relative to backend cwd), so local uploads were
showing as untracked. Adding `/backend/data` keeps test uploads out
of the index.

Lockstep version bump to 0.11.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 10:46:01 +02:00
parent 08e4f4fa18
commit dee7f1d160
9 changed files with 139 additions and 29 deletions

6
.gitignore vendored
View File

@@ -9,8 +9,12 @@
/frontend/test-results /frontend/test-results
/frontend/playwright-report /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 /data
/backend/data
# Env # Env
.env .env

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.11.0" version = "0.11.1"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -13,16 +13,23 @@ pub struct Bookmark {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// `Bookmark` enriched with the chapter's reader-facing number, so the /// `Bookmark` enriched with the parent manga's title + cover and the
/// frontend can build `/manga/{id}/chapter/{number}` links without an /// chapter's reader-facing number, so the /bookmarks page can render a
/// extra round-trip per bookmark. Populated by a LEFT JOIN; `null` for /// 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 /// manga-level bookmarks or for chapter bookmarks whose chapter has
/// since been deleted (`chapter_id` is `ON DELETE SET NULL`). /// since been deleted (`bookmarks.chapter_id` is `ON DELETE SET NULL`).
#[derive(Debug, Clone, Serialize, FromRow)] #[derive(Debug, Clone, Serialize, FromRow)]
pub struct BookmarkSummary { pub struct BookmarkSummary {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub manga_id: Uuid, pub manga_id: Uuid,
pub manga_title: String,
pub manga_cover_image_path: Option<String>,
pub chapter_id: Option<Uuid>, pub chapter_id: Option<Uuid>,
pub chapter_number: Option<i32>, pub chapter_number: Option<i32>,
pub page: Option<i32>, pub page: Option<i32>,

View File

@@ -53,11 +53,14 @@ pub async fn list_for_user(
b.id, b.id,
b.user_id, b.user_id,
b.manga_id, b.manga_id,
m.title AS manga_title,
m.cover_image_path AS manga_cover_image_path,
b.chapter_id, b.chapter_id,
c.number AS chapter_number, c.number AS chapter_number,
b.page, b.page,
b.created_at b.created_at
FROM bookmarks b FROM bookmarks b
INNER JOIN mangas m ON m.id = b.manga_id
LEFT JOIN chapters c ON c.id = b.chapter_id LEFT JOIN chapters c ON c.id = b.chapter_id
WHERE b.user_id = $1 WHERE b.user_id = $1
ORDER BY b.created_at DESC ORDER BY b.created_at DESC

View File

@@ -402,6 +402,11 @@ async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
.expect("chapter-level bookmark present"); .expect("chapter-level bookmark present");
assert_eq!(chapter_bookmark["chapter_number"], 7); assert_eq!(chapter_bookmark["chapter_number"], 7);
assert_eq!(chapter_bookmark["page"], 4); 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 let manga_bookmark = items
.iter() .iter()
@@ -411,6 +416,7 @@ async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
manga_bookmark["chapter_number"].is_null(), manga_bookmark["chapter_number"].is_null(),
"manga-level bookmark must have a null chapter_number" "manga-level bookmark must have a null chapter_number"
); );
assert_eq!(manga_bookmark["manga_title"], "Berserk");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]

View File

@@ -23,9 +23,18 @@ const bookmarkFixture = {
page: null, page: null,
created_at: '2026-01-01T00:00:00Z' 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) { async function setupAuthenticatedBookmarkFlow(page: Page) {
let bookmarks: typeof bookmarkFixture[] = []; let bookmarks: typeof enrichedBookmarkFixture[] = [];
await page.route('**/api/v1/auth/me', (route) => await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({ route.fulfill({
@@ -67,7 +76,11 @@ async function setupAuthenticatedBookmarkFlow(page: Page) {
); );
await page.route('**/api/v1/bookmarks', (route) => { await page.route('**/api/v1/bookmarks', (route) => {
if (route.request().method() === 'POST') { 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({ route.fulfill({
status: 201, status: 201,
contentType: 'application/json', 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. // The /bookmarks list reflects it.
await page.goto('/bookmarks'); 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. // Toggle off from the manga page.
await page.goto(`/manga/${mangaId}`); await page.goto(`/manga/${mangaId}`);

View File

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

View File

@@ -8,9 +8,17 @@ export type Bookmark = {
/** /**
* Reader-facing chapter number, populated by the backend's LEFT * Reader-facing chapter number, populated by the backend's LEFT
* JOIN when listing. `null` for manga-level bookmarks and for * 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; 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; page: number | null;
created_at: string; created_at: string;
}; };

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fileUrl } from '$lib/api/client';
let { data } = $props(); let { data } = $props();
const authenticated = $derived(data.authenticated); const authenticated = $derived(data.authenticated);
const bookmarks = $derived(data.bookmarks); const bookmarks = $derived(data.bookmarks);
@@ -24,21 +26,46 @@
{:else} {:else}
<ul class="bookmark-list" data-testid="bookmark-list"> <ul class="bookmark-list" data-testid="bookmark-list">
{#each bookmarks as b (b.id)} {#each bookmarks as b (b.id)}
<li> <li class="bookmark">
<a href="/manga/{b.manga_id}" class="cover-link" aria-hidden="true" tabindex="-1">
{#if b.manga_cover_image_path}
<img
src={fileUrl(b.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">📖</div>
{/if}
</a>
<div class="meta">
<a
href="/manga/{b.manga_id}"
class="title"
data-testid="bookmark-title"
>
{b.manga_title ?? 'Unknown manga'}
</a>
{#if b.chapter_id && b.chapter_number != null} {#if b.chapter_id && b.chapter_number != null}
<a href="/manga/{b.manga_id}/chapter/{b.chapter_number}"> <a
Chapter {b.chapter_number} href="/manga/{b.manga_id}/chapter/{b.chapter_number}"
{#if b.page != null && b.page > 0}— page {b.page}{/if} class="target"
>
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}
</a> </a>
{:else if b.chapter_id} {:else if b.chapter_id}
<!-- Chapter bookmark whose chapter was deleted; fall <!-- Chapter bookmark whose chapter was deleted;
back to the manga overview rather than emit a chapter_id != null but chapter_number == null
broken link to a number we don't have. --> because the LEFT JOIN found nothing. -->
<a href="/manga/{b.manga_id}">Chapter bookmark (chapter removed)</a> <span class="target muted">(chapter removed)</span>
{:else} {:else}
<a href="/manga/{b.manga_id}">Manga bookmark</a> <span class="target muted">Whole manga</span>
{/if} {/if}
<span class="created">{new Date(b.created_at).toLocaleDateString()}</span> <span class="created">
Bookmarked {new Date(b.created_at).toLocaleDateString()}
</span>
</div>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -49,12 +76,51 @@
list-style: none; list-style: none;
padding: 0; padding: 0;
} }
.bookmark-list li { .bookmark {
padding: 0.5rem 0; display: grid;
grid-template-columns: 64px 1fr;
gap: 1rem;
align-items: start;
padding: 0.75rem 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 64px;
height: 96px;
object-fit: cover;
border-radius: 4px;
background: #f0f0f0;
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #999;
user-select: none;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1rem;
}
.target {
font-size: 0.95rem;
}
.muted {
color: #888;
}
.created { .created {
color: #888; color: #888;
margin-left: 0.5rem; font-size: 0.85rem;
} }
</style> </style>