Compare commits

...

12 Commits

Author SHA1 Message Date
MechaCat02
9c4a93c058 fix(reader): harden swipe/long-press coexistence and next-chapter prefetch
All checks were successful
deploy / test-backend (push) Successful in 32m21s
deploy / test-frontend (push) Successful in 10m33s
deploy / build-and-push (push) Successful in 11m40s
deploy / deploy (push) Successful in 18s
- A long-press now cancels any pending swipe, so a hold-then-drag gesture
  opens the action sheet without also turning the page (regression-tested).
- The next-chapter prefetch no longer retries on every page flip when the
  endpoint fails (guard clears only when the next chapter changes), and it
  clears a previous chapter's warmed images on navigation so stale hidden
  <img>s don't linger.
- Tap zones set touch-action: pan-y so vertical panning stays native while
  horizontal swipes reach our handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:57:36 +02:00
MechaCat02
bfaa166e0a fix(home): keep finished series off the Continue-reading shelf
A "Continue reading" shelf listing series the reader already finished (read
to the last page, nothing new) is odd. Expose the last-read chapter's
page_count on the read-progress list, and filter the shelf to drop entries
that are caught up (on the last page of the latest chapter with no new
chapters). Mid-chapter and has-new-chapters entries stay; the full history
view is unaffected. Finished detection is a pure, unit-tested helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:52:58 +02:00
MechaCat02
3824eaafb2 fix(history): count distinct new chapter numbers, authoritative on detail
new_chapters_count over-counted when scanlations share a chapter number
((manga_id, number) is non-unique, migration 0013): COUNT(*) tallied rows,
so duplicate-numbered chapters inflated the badge. Switch both the list and
per-manga read-progress queries to COUNT(DISTINCT number), and drop the dead
COALESCE (an empty set counts as 0, and the NULL-number case is handled by
the predicate). Fix the misleading comment.

Expose new_chapters_count on GET /me/read-progress/:manga_id and drive the
detail page's "N new" badge from it, instead of recomputing over the
paginated chapter list — which under-counted series past 50 chapters and
disagreed with the homepage shelf. Read markers stay client-side over the
loaded chapters and are documented as by-number (all we persist).

Adds duplicate-scanlation tests at both endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:48:36 +02:00
MechaCat02
f1349100d7 feat(reader): prefetch the next chapter's first pages near the end
When the reader reaches within two pages of the end of a chapter (tracked in
both modes off the furthest page reached), warm the next chapter's first few
page images via hidden imgs so flipping over renders instantly. Fetched once
per next-chapter id, guarded against mid-flight chapter changes, and retried
on failure. e2e asserts the prefetch is absent at the chapter start and
present (with the next chapter's URLs) near the end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:28:06 +02:00
MechaCat02
3f1a5e9c41 feat(reader): swipe left/right to turn pages in single mode
Wire horizontal swipe into the mobile tap-zone overlay (single mode only,
so continuous mode keeps native vertical scroll). A leftward swipe past the
threshold turns to the next page, rightward to the previous; a mostly-
vertical drag is ignored so panning a tall page never turns it. The gesture
suppresses the synthesized zone tap so it doesn't double-fire. Swipe
classification is a pure, unit-tested helper; e2e drives real touch pointer
events for horizontal (both directions) and vertical cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:23:34 +02:00
MechaCat02
c212deb7b0 feat(reader): add a keyboard-shortcut help overlay
Press `?` in the reader to open a Sheet documenting the existing bindings
(arrows/J/K paging, Home/End, chapter nav, Esc); Esc or the close button
dismisses it. While open, the reader swallows navigation keys so paging
can't happen behind the overlay. e2e covers open-on-? and close-on-Esc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:18:47 +02:00
MechaCat02
f5692ea109 feat(ui): show a tooltip with the full text on truncated titles
Add an `overflowTooltip` Svelte action that sets a native `title` only when
an element is actually clipped (ellipsis or line-clamp), re-checking on
resize. Native title is keyboard/touch reachable and zero-dep, unlike a
hover-only popover. Applied to the clipped titles on MangaCard, BookmarkList,
HistoryList, and the Continue-reading shelf.

Unit tests cover the overflow decision and the set/clear/update behaviour;
an e2e verifies a real-browser truncated card title gets the tooltip while a
short one does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:08:28 +02:00
MechaCat02
ad1689b818 feat(detail): link genre and tag chips to the filtered catalogue
Genre chips now deep-link to /?genres=<id> and user-tag chips to
/?tags=<id>, reusing the homepage catalogue's existing filter params —
turning the detail page's metadata into one-tap discovery. Tag chips keep
their remove affordance (the Chip's href branch preventDefaults the remove
button). e2e asserts both chip hrefs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:04:38 +02:00
MechaCat02
01d18e7ba2 feat(detail): mark read chapters and count new ones on the manga page
On the manga detail page, dim chapters at or before the reader's last-read
chapter (with an accent check + screen-reader "Read." label) and show a
"N new since last read" badge counting chapters that have landed since.
All client-side from the already-loaded chapter list and per-manga read
progress — no extra requests. The count reflects the loaded (paginated)
chapter list. Logic lives in a pure, unit-tested chapterProgress helper;
e2e covers the mid-series and never-opened cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:01:58 +02:00
MechaCat02
2267a83f6c feat(home): add a Continue-reading shelf with new-chapter badges
Surface a horizontal "Continue reading" shelf at the top of the homepage,
fed by the user's read-progress history. Each card jumps back to the exact
page they left off (deep-linking `?page=N` past the first) and flags how
many chapters have landed since — the personal new_chapters_count from the
read-progress list. The shelf is fetched after the catalogue load so the
public browse path stays unauthenticated; guests get an empty list (401
swallowed) and the shelf stays hidden.

Add new_chapters_count to the frontend ReadProgressSummary type to match
the backend. Component unit tests cover href/deep-link, badge visibility,
and the no-chapter fallback; e2e covers signed-in (shelf + badge) and
anonymous (hidden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:57:17 +02:00
MechaCat02
f318c3bf51 feat(history): report personal new-chapter count on read-progress list
Add `new_chapters_count` to each `GET /me/read-progress` item: how many
chapters sit past the reader's last-read chapter, computed by a correlated
subquery on chapter number. It is 0 when the reader is caught up, and 0
when the last-read chapter is unknown (manga-level progress or a deleted
chapter) so we never over-claim. (manga_id, number) is non-unique, so this
counts positions past the reader rather than distinct files.

Backs the personal "new since last read" indicator on the upcoming
Continue-reading shelf. Tests cover the mid-series, caught-up, and
no-read-chapter cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:50:49 +02:00
MechaCat02
cf8971faae fix(reader): render pages at a consistent width instead of thin stripes
Reader page sizing was height-driven — `max-height: 90vh` in single mode
and unbounded natural width in continuous mode. A long vertical page hit
the height cap first and, preserving aspect ratio, collapsed into a thin
vertical stripe; narrow and wide scans rendered at different widths.

Switch both modes to width-driven sizing via a capped `--reader-page-width`
reading column (min(100%, 700px)): every page renders at the same width
regardless of intrinsic dimensions, and tall pages extend downward and
scroll instead of shrinking. Top-align the single-mode grid and make the
prev/next chevrons sticky so they stay reachable on a long page.

Add a Playwright spec asserting equal rendered width across differently-
proportioned pages and that a tall page exceeds the viewport height. The
page-context-menu fixture served a 1x1 image that now renders as a 700x700
square taller than the viewport, making Playwright scroll it into view
before right-clicking — a synthetic scroll that trips the menu's by-design
close-on-scroll. Give that fixture a realistic landscape aspect so the page
fits the viewport, matching real user behaviour (no scroll on right-click).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:45:59 +02:00
35 changed files with 1741 additions and 28 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -24,8 +24,19 @@ pub struct ReadProgressSummary {
/// `None` when the chapter was deleted after this row was written
/// (FK ON DELETE SET NULL on `chapter_id`).
pub chapter_number: Option<i32>,
/// Page count of the last-read chapter (`None` when unknown / deleted).
/// Lets a client distinguish a finished series (read to the last page,
/// nothing new) from one still in progress — e.g. to keep finished
/// series off a "Continue reading" shelf.
pub chapter_page_count: Option<i32>,
pub page: i32,
pub updated_at: DateTime<Utc>,
/// How many chapters sit past the reader's last-read chapter (by
/// chapter number) — a personal "new since you last read" count.
/// `0` when the reader is caught up or when `chapter_number` is
/// unknown (manga-level progress or a deleted chapter), so we never
/// claim chapters are new when we can't place the reader.
pub new_chapters_count: i64,
}
/// Returned by `GET /me/read-progress/:manga_id`. Same shape as
@@ -40,6 +51,11 @@ pub struct ReadProgressForManga {
pub chapter_number: Option<i32>,
pub page: i32,
pub updated_at: DateTime<Utc>,
/// Distinct chapter numbers past the last-read chapter — the detail
/// page's authoritative "new since last read" count (computed over all
/// chapters, not the client's paginated list). `0` when caught up or
/// the position is unknown. See [`ReadProgressSummary::new_chapters_count`].
pub new_chapters_count: i64,
}
#[derive(Debug, Clone, Deserialize)]

View File

@@ -80,7 +80,17 @@ pub async fn get_for_manga(
rp.chapter_id,
c.number AS chapter_number,
rp.page,
rp.updated_at
rp.updated_at,
-- Distinct chapter numbers past the reader's last-read chapter
-- (see list_for_user for the non-unique-number rationale). 0
-- when the last-read chapter is unknown.
(
SELECT count(DISTINCT c2.number)
FROM chapters c2
WHERE c2.manga_id = rp.manga_id
AND c.number IS NOT NULL
AND c2.number > c.number
) AS new_chapters_count
FROM read_progress rp
LEFT JOIN chapters c ON c.id = rp.chapter_id
WHERE rp.user_id = $1 AND rp.manga_id = $2
@@ -131,8 +141,23 @@ pub async fn list_for_user(
m.cover_image_path AS manga_cover_image_path,
rp.chapter_id,
c.number AS chapter_number,
c.page_count AS chapter_page_count,
rp.page,
rp.updated_at
rp.updated_at,
-- Personal "new since last read": how many distinct chapter
-- numbers sit past the reader's last-read chapter.
-- COUNT(DISTINCT number) — not COUNT(*) — because
-- (manga_id, number) is non-unique (scanlations share a
-- number, migration 0013), so raw rows would over-count. When
-- `c.number` is NULL (manga-level progress or a deleted
-- chapter) the predicate matches no rows, yielding 0.
(
SELECT count(DISTINCT c2.number)
FROM chapters c2
WHERE c2.manga_id = rp.manga_id
AND c.number IS NOT NULL
AND c2.number > c.number
) AS new_chapters_count
FROM read_progress rp
JOIN mangas m ON m.id = rp.manga_id
LEFT JOIN chapters c ON c.id = rp.chapter_id

View File

@@ -164,6 +164,199 @@ async fn list_is_per_user_only(pool: PgPool) {
assert_eq!(body["items"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn list_reports_new_chapters_since_last_read(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// Three chapters exist; the reader is on chapter 1, so chapters 2 and
// 3 are "new since last read".
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"][0]["new_chapters_count"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_reports_last_read_chapter_page_count(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// seed_chapter uploads a single page, so page_count is 1.
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
// Exposes the last-read chapter's page count so a client can tell a
// finished series (on the last page) from one still in progress.
assert_eq!(body["items"][0]["chapter_page_count"], 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_new_chapters_count_is_zero_at_latest_chapter(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let ch2 = seed_chapter(&h.app, &cookie, manga_id, 2).await;
// Caught up to the last chapter → nothing newer.
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch2, "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"][0]["new_chapters_count"], 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_new_chapters_count_is_zero_without_a_read_chapter(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
// Progress recorded without a chapter (manga-level) — we can't place
// the reader among the chapters, so we don't claim any are "new".
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"][0]["new_chapters_count"], 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_new_chapters_count_dedupes_same_numbered_scanlations(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// (manga_id, number) is non-unique — two scanlations of chapter 2.
// "New since last read" should count distinct chapter numbers past the
// reader (2, 3 → 2), not raw rows (2, 2, 3 → 3).
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"][0]["new_chapters_count"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_reports_new_chapters_count(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let ch1 = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
// Duplicate scanlation of chapter 3 must not inflate the count.
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 3).await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": ch1, "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
// Distinct numbers past chapter 1: {2, 3} → 2.
assert_eq!(body["new_chapters_count"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_new_chapters_count_zero_without_read_chapter(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let _ = seed_chapter(&h.app, &cookie, manga_id, 2).await;
// Manga-level progress (no chapter) → position unknown → 0.
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
)
.await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["new_chapters_count"], 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_returns_404_when_unread(pool: PgPool) {
let h = common::harness(pool);

View File

@@ -0,0 +1,60 @@
import { test, expect, type Page } from './fixtures';
// Genre and user-tag chips on the manga detail page deep-link into the
// homepage catalogue pre-filtered by that genre / tag.
const mangaId = 'a1111111-1111-1111-1111-111111111111';
const genreId = 'g0000000-0000-0000-0000-000000000005';
const tagId = 't0000000-0000-0000-0000-000000000042';
async function mockDetail(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/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no' } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', 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: [{ id: genreId, name: 'Fantasy' }],
tags: [{ id: tagId, name: 'isekai', added_by: null }],
content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('genre and tag chips link to the filtered catalogue', async ({ page }) => {
await mockDetail(page);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId(`genre-chip-${genreId}`)).toHaveAttribute(
'href',
`/?genres=${genreId}`
);
await expect(page.getByTestId(`tag-chip-${tagId}`)).toHaveAttribute(
'href',
`/?tags=${tagId}`
);
});

View File

@@ -0,0 +1,124 @@
import { test, expect, type Page } from './fixtures';
// The homepage "Continue reading" shelf: visible with a per-manga new-chapter
// badge when the user has reading history, and absent for anonymous visitors
// (the read-progress fetch 401s → empty → hidden).
const emptyMangas = { items: [], page: { limit: 50, offset: 0, total: 0 } };
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/genres*', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
await page.route('**/api/v1/mangas*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(emptyMangas)
})
);
}
test('shows the continue-reading shelf with a new-chapter badge when signed in', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', is_admin: false } })
})
);
await page.route('**/api/v1/me/read-progress*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
manga_id: 'm1',
manga_title: 'Berserk',
manga_cover_image_path: null,
chapter_id: 'c1',
chapter_number: 3,
chapter_page_count: 20,
page: 4,
updated_at: '2026-01-01T00:00:00Z',
new_chapters_count: 2
}
],
page: { limit: 50, offset: 0, total: 1 }
})
})
);
await page.goto('/');
const card = page.getByTestId('continue-card-m1');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('href', '/manga/m1/chapter/c1?page=4');
await expect(page.getByTestId('continue-new-badge-m1')).toContainText('2');
});
test('keeps finished series off the shelf but shows in-progress ones', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', is_admin: false } }) })
);
await page.route('**/api/v1/me/read-progress*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
// Finished: last page of the latest chapter, nothing new.
{
manga_id: 'done', manga_title: 'Finished', manga_cover_image_path: null,
chapter_id: 'dc', chapter_number: 5, chapter_page_count: 10, page: 10,
updated_at: '2026-01-02T00:00:00Z', new_chapters_count: 0
},
// In progress: mid-chapter.
{
manga_id: 'wip', manga_title: 'Ongoing', manga_cover_image_path: null,
chapter_id: 'wc', chapter_number: 2, chapter_page_count: 10, page: 3,
updated_at: '2026-01-01T00:00:00Z', new_chapters_count: 0
}
],
page: { limit: 50, offset: 0, total: 2 }
})
})
);
await page.goto('/');
await expect(page.getByTestId('continue-card-wip')).toBeVisible();
await expect(page.getByTestId('continue-card-done')).toHaveCount(0);
});
test('hides the continue-reading shelf for anonymous visitors', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
})
);
await page.route('**/api/v1/me/read-progress*', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
})
);
await page.goto('/');
// The catalogue heading confirms the page rendered before asserting absence.
await expect(page.getByRole('heading', { name: 'Mangas' })).toBeVisible();
await expect(page.getByTestId('continue-shelf')).toHaveCount(0);
});

View File

@@ -0,0 +1,88 @@
import { test, expect, type Page } from './fixtures';
// Detail page reflects the reader's personal progress: chapters at or before
// the last-read chapter are marked read, and a "N new since last read" badge
// counts chapters that have landed since.
const mangaId = 'a1111111-1111-1111-1111-111111111111';
const ch1 = 'c1111111-1111-1111-1111-111111111111';
const ch2 = 'c2222222-2222-2222-2222-222222222222';
const ch3 = 'c3333333-3333-3333-3333-333333333333';
// Oldest-first, as repo::chapter::list_for_manga returns them.
const chapters = [
{ id: ch1, manga_id: mangaId, number: 1, title: null, page_count: 8, created_at: '2026-01-01T00:00:00Z' },
{ id: ch2, manga_id: mangaId, number: 2, title: null, page_count: 10, created_at: '2026-02-01T00:00:00Z' },
{ id: ch3, manga_id: mangaId, number: 3, title: null, page_count: 10, created_at: '2026-03-01T00:00:00Z' }
];
async function mockDetail(
page: Page,
readProgress: {
chapter_id: string;
chapter_number: number;
page: number;
new_chapters_count: number;
} | null
) {
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/auth/me', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } }) })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chapters, page: { limit: 50, offset: 0, total: 3 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({
status: readProgress ? 200 : 404,
contentType: 'application/json',
body: readProgress ? JSON.stringify(readProgress) : JSON.stringify({ error: { code: 'not_found', message: 'no' } })
})
);
// getManga — registered last so it wins over the chapters glob above.
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', 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: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('marks read chapters and counts new ones when mid-series', async ({ page }) => {
// Backend reports 2 distinct new chapters (2 and 3) past chapter 1.
await mockDetail(page, { chapter_id: ch1, chapter_number: 1, page: 1, new_chapters_count: 2 });
await page.goto(`/manga/${mangaId}`);
// Chapter 1 is read; 2 and 3 are not.
await expect(page.getByTestId(`chapter-read-${ch1}`)).toBeVisible();
await expect(page.getByTestId(`chapter-read-${ch2}`)).toHaveCount(0);
await expect(page.getByTestId(`chapter-read-${ch3}`)).toHaveCount(0);
// Two chapters landed since last read.
await expect(page.getByTestId('new-chapters-summary')).toContainText('2 new');
});
test('no markers or new-badge when the manga was never opened', async ({ page }) => {
await mockDetail(page, null);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('chapter-list')).toBeVisible();
await expect(page.getByTestId(`chapter-read-${ch1}`)).toHaveCount(0);
await expect(page.getByTestId('new-chapters-summary')).toHaveCount(0);
});

View File

@@ -0,0 +1,66 @@
import { test, expect, type Page } from './fixtures';
// The overflowTooltip action sets a native `title` on a manga card's title
// only when it's actually truncated. Verified in a real browser (jsdom can't
// lay out, so scrollWidth/clientWidth are meaningless there).
const LONG_TITLE =
'An Extraordinarily Long Manga Title That Cannot Possibly Fit Within Two Clamped Lines Of A Narrow Card';
const SHORT_TITLE = 'Bleach';
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 mockHome(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/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/genres*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
await page.route('**/api/v1/me/read-progress*', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.route('**/api/v1/mangas*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [manga('m1', LONG_TITLE), manga('m2', SHORT_TITLE)],
page: { limit: 50, offset: 0, total: 2 }
})
})
);
}
test('adds a title tooltip to truncated card titles but not to ones that fit', async ({ page }) => {
await mockHome(page);
// Narrow (mobile) grid so the long title clamps and overflows.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const longLink = page.getByRole('link', { name: LONG_TITLE });
await expect(longLink).toBeVisible();
await expect(longLink).toHaveAttribute('title', LONG_TITLE);
const shortLink = page.getByRole('link', { name: SHORT_TITLE });
await expect(shortLink).not.toHaveAttribute('title');
});

View File

@@ -209,12 +209,15 @@ async function mockReader(
}
);
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
'hex'
);
// A landscape page image (wide aspect) so that, under the width-driven
// reader sizing, the rendered page is SHORTER than the viewport. A tall
// page would push its centre off-screen, and Playwright's `.click()`
// would scroll it into view first — that synthetic scroll trips the
// context menu's (by-design) close-on-scroll and flakes these tests.
// Real users right-click at the visible cursor with no such scroll.
const image = `<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="400"><rect width="1400" height="400" fill="#888"/></svg>`;
await page.route('**/api/v1/files/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/png', body: png })
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: image })
);
}

View File

@@ -0,0 +1,86 @@
import { test, expect, type Page } from './fixtures';
// As the reader nears the end of a chapter, the next chapter's first page
// images are prefetched (hidden) so flipping over is instant. They must NOT
// be present at the start of the chapter.
const MANGA_ID = 'm1';
const CH1 = 'c1';
const CH2 = 'c2';
const CH3 = 'c3';
function pages(prefix: string, n: number) {
return Array.from({ length: n }, (_, i) => ({
id: `${prefix}p${i + 1}`,
chapter_id: prefix,
page_number: i + 1,
storage_key: `${prefix}/${i + 1}`,
content_type: 'image/svg+xml'
}));
}
function chapter(id: string, number: number, count: number) {
return { id, manga_id: MANGA_ID, number, title: null, page_count: count, created_at: `2026-0${number}-01T00:00:00Z`, size_bytes: 0 };
}
async function mockReader(page: Page) {
await page.route('**/api/v1/**', async (route) => {
const { pathname } = new URL(route.request().url());
const json = (status: number, body: unknown) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
if (pathname.includes('/files/')) {
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000"/>' });
}
if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith(`/chapters/${CH1}/pages`)) return json(200, { pages: pages(CH1, 5) });
if (pathname.endsWith(`/chapters/${CH2}/pages`)) return json(200, { pages: pages(CH2, 4) });
if (pathname.endsWith(`/chapters/${CH3}/pages`)) return json(200, { pages: pages(CH3, 3) });
if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, 5));
if (pathname.endsWith(`/chapters/${CH2}`)) return json(200, chapter(CH2, 2, 4));
if (pathname.endsWith(`/chapters/${CH3}`)) return json(200, chapter(CH3, 3, 3));
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
// Oldest-first, as the reader expects (next = index + 1).
return json(200, { items: [chapter(CH1, 1, 5), chapter(CH2, 2, 4), chapter(CH3, 3, 3)], page: { limit: 200, offset: 0, total: 3 } });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', 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: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
});
}
test('prefetches the next chapter first pages only when near the end', async ({ page }) => {
await mockReader(page);
await page.goto(`/manga/${MANGA_ID}/chapter/${CH1}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
// Start of a 5-page chapter → not near the end → no next-chapter preload.
await expect(page.getByTestId('reader-next-preload')).toHaveCount(0);
// Jump to the last page.
await page.keyboard.press('End');
// Next chapter's first pages are now prefetched.
const preloads = page.getByTestId('reader-next-preload');
await expect(preloads.first()).toBeAttached();
await expect(preloads).toHaveCount(3);
await expect(preloads.first()).toHaveAttribute('src', /c2\/1/);
});
test('clears stale prefetch when navigating to the next chapter', async ({ page }) => {
await mockReader(page);
await page.goto(`/manga/${MANGA_ID}/chapter/${CH1}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
// Warm chapter 2's pages near the end of chapter 1.
await page.keyboard.press('End');
await expect(page.getByTestId('reader-next-preload')).toHaveCount(3);
// Navigate to chapter 2. At its start (not near the end) the previous
// prefetch must be cleared, even though chapter 2 has its own next (3).
await page.goto(`/manga/${MANGA_ID}/chapter/${CH2}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
await expect(page.getByTestId('reader-next-preload')).toHaveCount(0);
});

View File

@@ -0,0 +1,149 @@
import { test, expect, type Page } from './fixtures';
// Regression coverage for the reader page-sizing fix: pages must render at a
// consistent width regardless of their intrinsic dimensions, and a long
// (tall) page must extend downward and scroll instead of collapsing into a
// thin vertical stripe.
//
// The failure this guards against: sizing used to be height-driven
// (`max-height: 90vh` in single mode; unbounded natural width in continuous
// mode), so a tall webtoon strip shrank to a sliver and a narrow scan and a
// wide scan rendered at different widths.
const MANGA_ID = 'm1';
const CHAPTER_ID = 'c1';
// A capped reading column of ~700px on desktop; both modes should honour it.
const READING_WIDTH = 700;
const manga = {
id: MANGA_ID,
title: 'Sizing Test',
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: [],
tags: [],
content_warnings: [],
chapter_storage_bytes: 0
};
const chapter = {
id: CHAPTER_ID,
manga_id: MANGA_ID,
number: 1,
title: null,
page_count: 2,
created_at: '2026-01-01T00:00:00Z',
size_bytes: 0
};
// Page 1 is wide (1400×900), page 2 is a tall webtoon strip (800×4000).
const pages = [
{ id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'p/wide', content_type: 'image/svg+xml' },
{ id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'p/tall', content_type: 'image/svg+xml' }
];
// An SVG with an explicit width/height gives the <img> a real intrinsic
// size, so the rendered aspect ratio matches a genuine page image.
function svg(w: number, h: number): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#888"/></svg>`;
}
async function mockReader(page: Page) {
// Single dispatcher over the whole API surface — takes precedence over
// the fixture's 503 fallback and avoids route-glob precedence puzzles.
await page.route('**/api/v1/**', async (route) => {
const { pathname } = new URL(route.request().url());
const json = (status: number, body: unknown) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
// Images.
if (pathname.includes('/files/p/wide')) {
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(1400, 900) });
}
if (pathname.includes('/files/p/tall')) {
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(800, 4000) });
}
// Auth: public, anonymous.
if (pathname.endsWith('/auth/config')) {
return json(200, { self_register_enabled: true, private_mode: false });
}
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith('/auth/me/preferences')) {
return json(401, { error: { code: 'unauthenticated', message: 'no' } });
}
// Reader data.
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
return json(200, { pages });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, chapter);
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
return json(200, { items: [chapter], page: { limit: 200, offset: 0, total: 1 } });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, manga);
if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) {
return json(401, { error: { code: 'unauthenticated', message: 'no' } });
}
console.warn(`[e2e] reader spec unmocked: ${pathname}`);
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
});
}
test('continuous mode renders all pages at a consistent capped width; tall pages scroll', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
await mockReader(page);
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const wide = page.getByTestId('reader-page-1');
const tall = page.getByTestId('reader-page-2');
await expect(wide).toBeVisible();
await expect(tall).toBeVisible();
const wideBox = await wide.boundingBox();
const tallBox = await tall.boundingBox();
const viewport = page.viewportSize();
if (!wideBox || !tallBox || !viewport) throw new Error('missing layout metrics');
// 1. Consistent width regardless of intrinsic dimensions.
expect(Math.abs(wideBox.width - tallBox.width)).toBeLessThanOrEqual(1);
// 2. Capped reading column on desktop (not full-bleed, not a stripe).
expect(wideBox.width).toBeGreaterThan(READING_WIDTH - 40);
expect(wideBox.width).toBeLessThanOrEqual(READING_WIDTH + 2);
// 3. The tall page extends past the viewport (scrolls, doesn't shrink).
expect(tallBox.height).toBeGreaterThan(viewport.height);
});
test('single mode renders a tall page at the capped width, not a thin stripe', async ({ page }) => {
await mockReader(page); // default mode is single
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const img = page.getByTestId('reader-page');
await expect(img).toBeVisible();
// Advance from the wide first page to the tall second page.
await page.getByTestId('reader-next').click();
await expect(img).toHaveAttribute('src', /tall/);
const box = await img.boundingBox();
const viewport = page.viewportSize();
if (!box || !viewport) throw new Error('missing layout metrics');
// Width is the capped reading column, not a sliver.
expect(box.width).toBeGreaterThan(READING_WIDTH - 40);
expect(box.width).toBeLessThanOrEqual(READING_WIDTH + 2);
// And the page runs past the viewport so the reader scrolls it.
expect(box.height).toBeGreaterThan(viewport.height);
});

View File

@@ -0,0 +1,61 @@
import { test, expect, type Page } from './fixtures';
// The reader exposes a keyboard-shortcut help overlay: `?` opens it, Esc
// (or the close button) dismisses it.
const MANGA_ID = 'm1';
const CHAPTER_ID = 'c1';
async function mockReader(page: Page) {
await page.route('**/api/v1/**', async (route) => {
const { pathname } = new URL(route.request().url());
const json = (status: number, body: unknown) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
if (pathname.includes('/files/')) {
return route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"/>'
});
}
if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
return json(200, {
pages: [
{ id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'k/1', content_type: 'image/svg+xml' },
{ id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'k/2', content_type: 'image/svg+xml' }
]
});
}
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) {
return json(200, { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 });
}
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
return json(200, { items: [{ id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }], page: { limit: 200, offset: 0, total: 1 } });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) {
return json(200, { id: MANGA_ID, title: 'Berserk', 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: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
}
if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
});
}
test('? opens the keyboard-shortcut overlay and Esc closes it', async ({ page }) => {
await mockReader(page);
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
await expect(page.getByTestId('reader-shortcuts')).toHaveCount(0);
await page.keyboard.press('Shift+Slash'); // "?"
await expect(page.getByTestId('reader-shortcuts')).toBeVisible();
// Lists at least one real binding.
await expect(page.getByTestId('reader-shortcuts')).toContainText('Next page');
await page.keyboard.press('Escape');
await expect(page.getByTestId('reader-shortcuts')).toBeHidden();
});

View File

@@ -0,0 +1,103 @@
import { test, expect, type Page } from './fixtures';
// Single-page reader: a horizontal swipe turns the page; a mostly-vertical
// drag does not (so panning a tall page never turns it). Swipe is wired into
// the mobile tap-zone overlay, which only exists in single mode.
const MANGA_ID = 'm1';
const CHAPTER_ID = 'c1';
async function mockReader(page: Page) {
await page.route('**/api/v1/**', async (route) => {
const { pathname } = new URL(route.request().url());
const json = (status: number, body: unknown) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
if (pathname.includes('/files/')) {
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000"/>' });
}
if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
return json(200, { pages: [
{ id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'k/1', content_type: 'image/svg+xml' },
{ id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'k/2', content_type: 'image/svg+xml' }
] });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 });
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) return json(200, { items: [{ id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }], page: { limit: 200, offset: 0, total: 1 } });
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', 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: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
});
}
// Dispatch a touch pointer swipe on the tap-zone overlay.
async function swipe(page: Page, dx: number, dy: number) {
await page.evaluate(
({ dx, dy }) => {
const el = document.querySelector('[data-testid="reader-tap-center"]');
if (!el) throw new Error('tap zone not found');
const sx = 300;
const sy = 500;
const ev = (type: string, x: number, y: number) =>
new PointerEvent(type, { clientX: x, clientY: y, pointerType: 'touch', bubbles: true, cancelable: true });
el.dispatchEvent(ev('pointerdown', sx, sy));
el.dispatchEvent(ev('pointermove', sx + dx / 2, sy + dy / 2));
el.dispatchEvent(ev('pointerup', sx + dx, sy + dy));
},
{ dx, dy }
);
}
// Dispatch pointerdown, hold (so long-press fires), then swipe+lift.
async function holdThenSwipe(page: Page, holdMs: number, dx: number) {
await page.evaluate(() => {
const el = document.querySelector('[data-testid="reader-tap-center"]');
if (!el) throw new Error('tap zone not found');
el.dispatchEvent(new PointerEvent('pointerdown', { clientX: 300, clientY: 500, pointerType: 'touch', bubbles: true, cancelable: true }));
});
await page.waitForTimeout(holdMs);
await page.evaluate(({ dx }) => {
const el = document.querySelector('[data-testid="reader-tap-center"]');
if (!el) throw new Error('tap zone not found');
el.dispatchEvent(new PointerEvent('pointermove', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true }));
el.dispatchEvent(new PointerEvent('pointerup', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true }));
}, { dx });
}
test('a long-press then swipe does not also turn the page', async ({ page }) => {
await mockReader(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const img = page.getByTestId('reader-page');
await expect(img).toHaveAttribute('src', /k\/1/);
// Hold past the 450ms long-press threshold, then swipe left before lifting.
await holdThenSwipe(page, 550, -200);
// The long-press consumed the gesture — the page must not have turned.
await expect(img).toHaveAttribute('src', /k\/1/);
});
test('horizontal swipe turns the page; vertical drag does not', async ({ page }) => {
await mockReader(page);
await page.setViewportSize({ width: 390, height: 844 }); // mobile → tap zones render
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const img = page.getByTestId('reader-page');
await expect(img).toHaveAttribute('src', /k\/1/);
// A near-vertical drag must NOT turn the page.
await swipe(page, 6, -220);
await expect(img).toHaveAttribute('src', /k\/1/);
// A leftward horizontal swipe advances to the next page.
await swipe(page, -200, 8);
await expect(img).toHaveAttribute('src', /k\/2/);
// A rightward horizontal swipe goes back.
await swipe(page, 200, 8);
await expect(img).toHaveAttribute('src', /k\/1/);
});

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, afterEach } from 'vitest';
import { isOverflowing, overflowTooltip } from './overflowTooltip';
// jsdom doesn't lay out, so scrollWidth/clientWidth are always 0. Stub them
// to model a clipped vs fitting element.
function makeEl(dims: {
scrollWidth?: number;
clientWidth?: number;
scrollHeight?: number;
clientHeight?: number;
}): HTMLElement {
const el = document.createElement('span');
for (const [k, v] of Object.entries(dims)) {
Object.defineProperty(el, k, { configurable: true, value: v });
}
document.body.appendChild(el);
return el;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('isOverflowing', () => {
it('is true when content is clipped horizontally', () => {
expect(isOverflowing(makeEl({ scrollWidth: 200, clientWidth: 100 }))).toBe(true);
});
it('is true when content is clipped vertically (line-clamp)', () => {
expect(
isOverflowing(makeEl({ scrollWidth: 100, clientWidth: 100, scrollHeight: 80, clientHeight: 40 }))
).toBe(true);
});
it('is false when the content fits', () => {
expect(isOverflowing(makeEl({ scrollWidth: 100, clientWidth: 100 }))).toBe(false);
});
});
describe('overflowTooltip action', () => {
it('sets a title with the text when the element is truncated', () => {
const el = makeEl({ scrollWidth: 200, clientWidth: 100 });
overflowTooltip(el, 'A very long manga title');
expect(el.getAttribute('title')).toBe('A very long manga title');
});
it('does not set a title when the text fits', () => {
const el = makeEl({ scrollWidth: 100, clientWidth: 100 });
overflowTooltip(el, 'Short');
expect(el.getAttribute('title')).toBeNull();
});
it('updates the title text when the bound value changes', () => {
const el = makeEl({ scrollWidth: 200, clientWidth: 100 });
const action = overflowTooltip(el, 'First');
expect(el.getAttribute('title')).toBe('First');
action.update('Second');
expect(el.getAttribute('title')).toBe('Second');
});
});

View File

@@ -0,0 +1,45 @@
// Svelte action: show the full text in a native `title` tooltip only when
// the element is actually truncated (ellipsis or line-clamp), and keep it in
// sync as the element resizes or the text changes.
//
// Native `title` is deliberate: it's keyboard/touch reachable and zero-dep,
// unlike a hover-only custom popover. Applied to clipped titles across list
// surfaces (MangaCard, BookmarkList, HistoryList).
/** True when the element's content is clipped horizontally (ellipsis) or
* vertically (line-clamp). A 1px slack absorbs sub-pixel rounding. */
export function isOverflowing(node: HTMLElement): boolean {
return (
node.scrollWidth - node.clientWidth > 1 ||
node.scrollHeight - node.clientHeight > 1
);
}
export function overflowTooltip(node: HTMLElement, text: string) {
let current = text;
function sync() {
if (isOverflowing(node)) node.setAttribute('title', current);
else node.removeAttribute('title');
}
// Re-check on size changes (viewport resize, font load, layout shifts).
// Guarded for environments without ResizeObserver (SSR / jsdom).
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => sync());
ro.observe(node);
}
sync();
return {
update(next: string) {
current = next;
sync();
},
destroy() {
ro?.disconnect();
}
};
}

View File

@@ -15,8 +15,15 @@ export type ReadProgressSummary = {
chapter_id: string | null;
/** `null` if the chapter was deleted after the progress was written. */
chapter_number: number | null;
/** Page count of the last-read chapter (`null` when unknown / deleted).
* Lets the shelf tell a finished series from one still in progress. */
chapter_page_count: number | null;
page: number;
updated_at: string;
/** Chapters past the reader's last-read chapter — a personal "new
* since you last read" count. `0` when caught up or when the position
* is unknown (manga-level progress / deleted chapter). */
new_chapters_count: number;
};
export type ReadProgressPage = {
@@ -75,6 +82,10 @@ export type ReadProgressForManga = {
chapter_number: number | null;
page: number;
updated_at: string;
/** Distinct chapter numbers past the last-read chapter — the
* authoritative "new since last read" count (over all chapters, not the
* detail page's paginated list). `0` when caught up / position unknown. */
new_chapters_count: number;
};
/**

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { isChapterRead } from './chapterProgress';
describe('isChapterRead', () => {
it('marks chapters at or before the last-read number as read', () => {
expect(isChapterRead(1, 3)).toBe(true);
expect(isChapterRead(3, 3)).toBe(true);
});
it('leaves later chapters unread', () => {
expect(isChapterRead(4, 3)).toBe(false);
});
it('marks nothing when the last-read position is unknown', () => {
expect(isChapterRead(1, null)).toBe(false);
});
});

View File

@@ -0,0 +1,17 @@
// Pure helper for reflecting a reader's personal progress on a manga's
// chapter list. Kept UI-free so the read/unread logic is unit-testable
// without rendering the detail page.
/**
* A chapter counts as read once its number is at or before the reader's
* last-read chapter number. An unknown last-read position (guest, never
* opened, or a deleted chapter) marks nothing — we never guess.
*/
export function isChapterRead(chapterNumber: number, lastReadNumber: number | null): boolean {
return lastReadNumber != null && chapterNumber <= lastReadNumber;
}
// Note: the "new since last read" *count* is computed server-side
// (distinct chapter numbers over ALL chapters — see
// repo::read_progress) and delivered on the read-progress payloads, so it
// isn't recomputed here over the detail page's paginated chapter list.

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import type { Bookmark } from '$lib/api/bookmarks';
import { overflowTooltip } from '$lib/actions/overflowTooltip';
import BookImage from '@lucide/svelte/icons/book-image';
let {
@@ -34,6 +35,7 @@
href="/manga/{b.manga_id}"
class="title"
data-testid="bookmark-title"
use:overflowTooltip={b.manga_title ?? 'Unknown manga'}
>
{b.manga_title ?? 'Unknown manga'}
</a>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import { overflowTooltip } from '$lib/actions/overflowTooltip';
import type { ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image';
// Horizontal "Continue reading" shelf for the homepage. Fed the user's
// read-progress history (newest first); each card jumps straight back to
// where they left off and flags how many chapters have landed since.
// Rendered only when there are entries (the homepage owns that gate), so
// this component has no empty state.
let {
entries,
testid = 'continue-shelf'
}: {
entries: ReadProgressSummary[];
testid?: string;
} = $props();
// Deep-link to the exact page when past the first; page 1 is the reader's
// default so the query is omitted to keep the URL clean. No chapter (a
// manga-level progress row) falls back to the manga page.
function continueHref(p: ReadProgressSummary): string {
if (p.chapter_id == null) return `/manga/${p.manga_id}`;
const base = `/manga/${p.manga_id}/chapter/${p.chapter_id}`;
return p.page > 1 ? `${base}?page=${p.page}` : base;
}
function targetLabel(p: ReadProgressSummary): string {
if (p.chapter_number == null) return `Page ${p.page}`;
const label = chapterLabel({ number: p.chapter_number, title: null });
return p.page > 1 ? `${label} · p ${p.page}` : label;
}
</script>
<section class="shelf" aria-label="Continue reading" data-testid="{testid}">
<h2 class="shelf-title">Continue reading</h2>
<ul class="track" data-testid="{testid}-list">
{#each entries as p (p.manga_id)}
<li>
<a
href={continueHref(p)}
class="card"
data-testid="continue-card-{p.manga_id}"
>
<span class="cover-wrap">
{#if p.manga_cover_image_path}
<img
src={fileUrl(p.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<span class="cover cover-placeholder">
<BookImage size={28} aria-hidden="true" />
</span>
{/if}
{#if p.new_chapters_count > 0}
<span
class="new-badge"
data-testid="continue-new-badge-{p.manga_id}"
aria-label="{p.new_chapters_count} new chapter{p.new_chapters_count === 1 ? '' : 's'} since you last read"
>
{p.new_chapters_count > 99 ? '99+' : p.new_chapters_count} new
</span>
{/if}
</span>
<span class="title" data-testid="continue-title-{p.manga_id}" use:overflowTooltip={p.manga_title}>{p.manga_title}</span>
<span class="target">{targetLabel(p)}</span>
</a>
</li>
{/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);
}
.track {
list-style: none;
display: flex;
gap: var(--space-3);
padding: 0 0 var(--space-2);
margin: 0;
overflow-x: auto;
scroll-snap-type: x proximity;
/* Momentum scrolling on iOS; keep the scrollbar unobtrusive. */
-webkit-overflow-scrolling: touch;
}
.card {
display: flex;
flex-direction: column;
width: 108px;
scroll-snap-align: start;
color: var(--text);
text-decoration: none;
}
.cover-wrap {
position: relative;
display: block;
line-height: 0;
}
.cover {
width: 108px;
height: 162px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.new-badge {
position: absolute;
top: var(--space-1);
right: var(--space-1);
padding: 0 6px;
height: 20px;
display: inline-flex;
align-items: center;
background: var(--primary);
color: var(--primary-contrast);
font-size: var(--font-xs);
font-weight: var(--weight-semibold);
line-height: 1;
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
pointer-events: none;
}
.title {
margin-top: var(--space-1);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card:hover .title {
color: var(--primary);
}
.target {
font-size: var(--font-xs);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import ContinueReadingShelf from './ContinueReadingShelf.svelte';
import type { ReadProgressSummary } from '$lib/api/read_progress';
afterEach(() => cleanup());
type Entry = ReadProgressSummary & { new_chapters_count: number };
function entry(over: Partial<Entry> = {}): Entry {
return {
manga_id: 'm1',
manga_title: 'Berserk',
manga_cover_image_path: null,
chapter_id: 'c1',
chapter_number: 3,
chapter_page_count: 20,
page: 3,
updated_at: '2026-01-01T00:00:00Z',
new_chapters_count: 0,
...over
};
}
describe('ContinueReadingShelf', () => {
it('renders a card per entry linking to the continue position', () => {
render(ContinueReadingShelf, {
props: {
entries: [
entry({ manga_id: 'm1', chapter_id: 'c1', page: 3 }),
entry({ manga_id: 'm2', chapter_id: 'c2', page: 1, manga_title: 'Vinland' })
]
}
});
// Page > 1 deep-links to the exact page; page 1 omits the query.
const first = screen.getByTestId('continue-card-m1');
expect(first.getAttribute('href')).toBe('/manga/m1/chapter/c1?page=3');
const second = screen.getByTestId('continue-card-m2');
expect(second.getAttribute('href')).toBe('/manga/m2/chapter/c2');
expect(screen.getByText('Berserk')).toBeTruthy();
expect(screen.getByText('Vinland')).toBeTruthy();
});
it('shows the new-chapter badge only when there are new chapters', () => {
render(ContinueReadingShelf, {
props: {
entries: [
entry({ manga_id: 'm1', new_chapters_count: 2 }),
entry({ manga_id: 'm2', new_chapters_count: 0 })
]
}
});
const badge = screen.getByTestId('continue-new-badge-m1');
expect(badge.textContent).toContain('2');
expect(screen.queryByTestId('continue-new-badge-m2')).toBeNull();
});
it('links to the manga when the progress has no chapter', () => {
render(ContinueReadingShelf, {
props: {
entries: [entry({ manga_id: 'm3', chapter_id: null, chapter_number: null })]
}
});
expect(screen.getByTestId('continue-card-m3').getAttribute('href')).toBe('/manga/m3');
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import { overflowTooltip } from '$lib/actions/overflowTooltip';
import type { ReadProgressSummary } from '$lib/api/read_progress';
import IconButton from '$lib/components/IconButton.svelte';
import BookImage from '@lucide/svelte/icons/book-image';
@@ -96,7 +97,12 @@
{/if}
</a>
<div class="meta">
<a href="/manga/{p.manga_id}" class="title" data-testid="{testid}-title">
<a
href="/manga/{p.manga_id}"
class="title"
data-testid="{testid}-title"
use:overflowTooltip={p.manga_title}
>
{p.manga_title}
</a>
<span class="target">

View File

@@ -12,8 +12,10 @@ function entry(over: Partial<ReadProgressSummary> = {}): ReadProgressSummary {
manga_cover_image_path: null,
chapter_id: 'c1',
chapter_number: 5,
chapter_page_count: 10,
page: 1,
updated_at: '2026-01-02T00:00:00Z',
new_chapters_count: 0,
...over
};
}

View File

@@ -2,6 +2,7 @@
import { fileUrl } from '$lib/api/client';
import type { Manga } from '$lib/api/client';
import type { AuthorRef, GenreRef } from '$lib/api/mangas';
import { overflowTooltip } from '$lib/actions/overflowTooltip';
import BookImage from '@lucide/svelte/icons/book-image';
let {
@@ -77,7 +78,7 @@
{/if}
</a>
<div class="meta">
<a href="/manga/{manga.id}" class="title">{manga.title}</a>
<a href="/manga/{manga.id}" class="title" use:overflowTooltip={manga.title}>{manga.title}</a>
{#if authors.length > 0}
<span class="author">{authors.map((a) => a.name).join(', ')}</span>
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { swipeDirection } from '$lib/swipe';
let {
onPrev,
@@ -27,6 +28,9 @@
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let pressStart: { x: number; y: number } | null = null;
// Swipe origin — tracked for every touch pointerdown (independent of the
// long-press timer) so a horizontal drag can turn the page.
let swipeStart: { x: number; y: number } | null = null;
// Self-expiring suppression: cleared either by an actual click
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
// timer below. The latter matters when the user lifts off the
@@ -54,14 +58,21 @@
}
function onPointerDown(e: PointerEvent) {
if (!onLongPress) return;
if (e.pointerType !== 'touch') return;
// Record the swipe origin for every touch, whether or not long-press
// is wired up.
swipeStart = { x: e.clientX, y: e.clientY };
if (!onLongPress) return;
clearPress();
pressStart = { x: e.clientX, y: e.clientY };
const startX = e.clientX;
const startY = e.clientY;
pressTimer = setTimeout(() => {
pressTimer = null;
// A long-press consumed the gesture — cancel any swipe so a
// subsequent drag-then-lift (finger held, then moved) doesn't
// also turn the page on top of opening the action sheet.
swipeStart = null;
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
@@ -79,17 +90,35 @@
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
}
function onPointerUp() {
function onPointerUp(e: PointerEvent) {
if (swipeStart && e.pointerType === 'touch') {
const dir = swipeDirection(e.clientX - swipeStart.x, e.clientY - swipeStart.y);
if (dir) {
// A swipe consumed the gesture — suppress the click the
// browser may synthesize on the zone so it doesn't also fire
// that zone's tap action (reusing the long-press suppressor).
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
(dir === 'next' ? onNext : onPrev)();
}
}
swipeStart = null;
clearPress();
}
function onPointerCancel() {
swipeStart = null;
clearPress();
}
function onScroll() {
// Scrolling while pressed almost always means the user is
// panning, not deliberately holding. Cancel.
swipeStart = null;
clearPress();
}
@@ -175,6 +204,9 @@
padding: 0;
margin: 0;
cursor: pointer;
/* Let vertical panning through to the page but keep horizontal
gestures for our swipe handler rather than browser scroll. */
touch-action: pan-y;
/* No visible style — these are interaction surfaces, not
controls. Focus-visible is honored so keyboard users get
a ring if they tab into one. */

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { isCaughtUp } from './continueReading';
const base = {
chapter_number: 5 as number | null,
chapter_page_count: 10 as number | null,
page: 3,
new_chapters_count: 0
};
describe('isCaughtUp', () => {
it('is true on the last page of the latest chapter with nothing new', () => {
expect(isCaughtUp({ ...base, page: 10 })).toBe(true);
});
it('is false mid-chapter (still something to continue)', () => {
expect(isCaughtUp({ ...base, page: 3 })).toBe(false);
});
it('is false when newer chapters exist', () => {
expect(isCaughtUp({ ...base, page: 10, new_chapters_count: 2 })).toBe(false);
});
it('is false when the last-read position is unknown', () => {
expect(isCaughtUp({ ...base, page: 10, chapter_number: null })).toBe(false);
});
it('is false when the chapter page count is unknown or zero', () => {
expect(isCaughtUp({ ...base, page: 10, chapter_page_count: null })).toBe(false);
expect(isCaughtUp({ ...base, page: 1, chapter_page_count: 0 })).toBe(false);
});
});

View File

@@ -0,0 +1,20 @@
import type { ReadProgressSummary } from '$lib/api/read_progress';
// Whether a read-progress entry has nothing left to continue: the reader is
// on the last page of the latest chapter and no newer chapters exist. Such
// finished series are kept off the homepage "Continue reading" shelf (the
// full history view still shows them).
export function isCaughtUp(
e: Pick<
ReadProgressSummary,
'new_chapters_count' | 'chapter_number' | 'chapter_page_count' | 'page'
>
): boolean {
return (
e.new_chapters_count === 0 &&
e.chapter_number != null &&
e.chapter_page_count != null &&
e.chapter_page_count > 0 &&
e.page >= e.chapter_page_count
);
}

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { swipeDirection } from './swipe';
describe('swipeDirection', () => {
it('classifies a leftward horizontal swipe as next', () => {
expect(swipeDirection(-100, 8)).toBe('next');
});
it('classifies a rightward horizontal swipe as prev', () => {
expect(swipeDirection(120, -10)).toBe('prev');
});
it('ignores a swipe shorter than the threshold', () => {
expect(swipeDirection(-20, 2)).toBeNull();
});
it('ignores a mostly-vertical drag so panning does not turn the page', () => {
expect(swipeDirection(-50, 90)).toBeNull();
});
});

23
frontend/src/lib/swipe.ts Normal file
View File

@@ -0,0 +1,23 @@
// Pure gesture helper for the reader's single-page swipe navigation. Kept
// UI-free so the "is this a page-turn swipe, and which way" decision is
// unit-testable without dispatching pointer events.
export const SWIPE_THRESHOLD_PX = 45;
/**
* Classifies a pointer displacement as a page-turn swipe. Returns `'next'`
* for a leftward swipe, `'prev'` for a rightward one, or `null` when the
* gesture is too short or too vertical to be a deliberate horizontal swipe
* (so vertical panning never turns the page).
*/
export function swipeDirection(
dx: number,
dy: number,
threshold: number = SWIPE_THRESHOLD_PX
): 'next' | 'prev' | null {
if (Math.abs(dx) < threshold) return null;
// Must be predominantly horizontal — a diagonal or vertical drag is not
// a page turn.
if (Math.abs(dx) <= Math.abs(dy)) return null;
return dx < 0 ? 'next' : 'prev';
}

View File

@@ -22,7 +22,13 @@
sortLabel as composeSortLabel,
sortUrlParams
} from '$lib/mangaSort';
import {
listMyReadProgressOrEmpty,
type ReadProgressSummary
} from '$lib/api/read_progress';
import { isCaughtUp } from '$lib/continueReading';
import Chip from '$lib/components/Chip.svelte';
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
@@ -37,6 +43,7 @@
const PAGE_SIZE = 50;
let mangas: MangaCardData[] = $state([]);
let continueEntries = $state<ReadProgressSummary[]>([]);
let search = $state('');
let sort = $state<MangaSort>(DEFAULT_SORT);
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
@@ -301,6 +308,18 @@
}
await hydrateFromUrl();
await load();
// Fetch the "Continue reading" shelf after the catalogue so the
// public browse path stays unauthenticated and unblocked. Returns
// empty for guests (401 swallowed), which hides the shelf.
try {
const progress = await listMyReadProgressOrEmpty();
// Drop finished series (read to the end, nothing new) — a
// "Continue reading" shelf is for what's still in progress.
continueEntries = progress.items.filter((e) => !isCaughtUp(e));
} catch {
// Never let a history hiccup break the catalogue — leave the
// shelf hidden.
}
});
// Track viewport in a separate $effect so the listener cleans up on
@@ -455,6 +474,10 @@
</a>
</div>
{#if continueEntries.length > 0}
<ContinueReadingShelf entries={continueEntries} />
{/if}
<form
onsubmit={onSubmit}
action="javascript:void(0)"

View File

@@ -13,6 +13,7 @@
} from '$lib/api/mangas';
import { resyncManga } from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import { isChapterRead } from '$lib/chapterProgress';
import { formatBytes } from '$lib/upload-validation';
import { listTags, type Tag } from '$lib/api/tags';
import type { ContentWarning } from '$lib/api/page_tags';
@@ -27,6 +28,7 @@
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import Check from '@lucide/svelte/icons/check';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import BookmarkIcon from '@lucide/svelte/icons/bookmark';
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
@@ -68,6 +70,17 @@
: null
);
/** The reader's last-read chapter number, used to mark chapters read.
* `null` for guests / never-read. Read-state is by chapter *number*
* (all we persist), so re-reading one scanlation marks same-numbered
* scanlations read too — intended, since "chapter N" is read. */
const lastReadNumber = $derived(readProgress?.chapter_number ?? null);
/** New chapters since last read. Authoritative backend count over ALL
* chapters (distinct numbers) — not recomputed over the paginated
* `chapters` list, which would under-count long series and disagree
* with the homepage shelf. `0` for guests / never-read. */
const newChapterCount = $derived(readProgress?.new_chapters_count ?? 0);
const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres);
/** Deduped content warnings across the manga's pages (analysis worker). */
@@ -418,7 +431,7 @@
<div class="chip-row" data-testid="manga-genres">
<span class="chip-row-label">Genres</span>
{#each genres as g (g.id)}
<Chip label={g.name} />
<Chip label={g.name} href={`/?genres=${g.id}`} testid={`genre-chip-${g.id}`} />
{/each}
</div>
{/if}
@@ -428,6 +441,8 @@
{#each tags as t (t.id)}
<Chip
label={t.name}
href={`/?tags=${t.id}`}
testid={`tag-chip-${t.id}`}
variant="soft"
onRemove={session.user && t.added_by === session.user.id
? () => removeTag(t)
@@ -597,7 +612,14 @@
<section aria-label="chapters">
<div class="chapters-head">
<h2>Chapters</h2>
<div class="chapters-head-left">
<h2>Chapters</h2>
{#if newChapterCount > 0}
<span class="new-chapters" data-testid="new-chapters-summary">
{newChapterCount} new since last read
</span>
{/if}
</div>
{#if contentBytes === null || contentBytes > 0}
<span class="content-size" data-testid="manga-content-size">
Content: {contentBytes === null ? '—' : formatBytes(contentBytes)}
@@ -624,7 +646,12 @@
{:else}
<ol class="chapter-list" data-testid="chapter-list">
{#each chapters as c (c.id)}
<li>
{@const read = isChapterRead(c.number, lastReadNumber)}
<li class:read data-testid={read ? `chapter-read-${c.id}` : undefined}>
{#if read}
<Check size={15} class="read-check" aria-hidden="true" />
<span class="sr-only">Read.</span>
{/if}
<a href="/manga/{manga.id}/chapter/{c.id}">
{chapterLabel(c)}
</a>
@@ -1009,6 +1036,40 @@
padding: var(--space-1) 0;
}
/* Already-read chapters (at or before the reader's last-read chapter)
dim so the eye is drawn to what's still unread; the check keeps its
accent colour as a positive "done" marker. */
.chapter-list li.read {
color: var(--text-muted);
}
.chapter-list li.read :global(svg) {
vertical-align: -2px;
margin-right: var(--space-1);
color: var(--primary);
}
.new-chapters {
color: var(--primary);
background: var(--primary-soft-bg);
border-radius: var(--radius-pill);
padding: 0 var(--space-2);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.pages {
color: var(--text-muted);
margin-left: var(--space-2);
@@ -1022,6 +1083,13 @@
gap: var(--space-3);
}
.chapters-head-left {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.content-size {
color: var(--text-muted);
font-size: var(--font-sm);

View File

@@ -6,7 +6,7 @@
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { chapterLabel } from '$lib/api/chapters';
import { chapterLabel, getChapterPages } from '$lib/api/chapters';
import { resyncChapter, analyzePage } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
@@ -110,6 +110,7 @@
// mode, or per-image timer in continuous mode) opens an action
// sheet that funnels into the same modals. Unauthenticated users
// see neither — there's nothing for them to act on.
let shortcutsOpen = $state(false);
let contextMenuOpen = $state(false);
let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 });
let activePageId = $state<string | null>(null);
@@ -657,6 +658,15 @@
// Don't hijack keys while the user is typing in an input.
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
// `?` opens the keyboard-shortcut help overlay. While it's open,
// swallow every key here so paging can't happen behind it — the
// Sheet owns its own Escape/close.
if (e.key === '?') {
e.preventDefault();
shortcutsOpen = true;
return;
}
if (shortcutsOpen) return;
// Esc always exits fullscreen if active — applies in both
// modes, including when the bars are hidden. Handled before
// the per-mode switches so it doesn't get shadowed.
@@ -949,6 +959,45 @@
}
}
// ---- Next-chapter image preloading ----
// When the reader nears the end of the current chapter, warm the next
// chapter's first few page images so flipping over is instant. Works in
// both modes off the furthest page reached (single: `index`; continuous:
// the IntersectionObserver high-water `progressPage`).
const PRELOAD_WITHIN_PAGES = 2;
const NEXT_PRELOAD_COUNT = 3;
let nextPreloadUrls = $state<string[]>([]);
let preloadedForChapterId: string | null = null;
const furthestPage = $derived(mode === 'single' ? index + 1 : progressPage);
$effect(() => {
const nc = nextChapter;
// Drop a previous chapter's warmed images once the next chapter
// changes (e.g. after navigating), so stale <img>s don't linger.
if (preloadedForChapterId !== null && (!nc || preloadedForChapterId !== nc.id)) {
preloadedForChapterId = null;
nextPreloadUrls = [];
}
if (!nc || pages.length === 0) return;
if (pages.length - furthestPage > PRELOAD_WITHIN_PAGES) return;
if (preloadedForChapterId === nc.id) return;
// Marked before the await and NOT reset on failure, so a broken
// endpoint can't re-fetch on every page flip near the end — the
// guard clears only when the next chapter itself changes (above).
preloadedForChapterId = nc.id;
getChapterPages(manga.id, nc.id)
.then((ps) => {
if (preloadedForChapterId !== nc.id) return; // chapter changed mid-flight
nextPreloadUrls = ps
.slice(0, NEXT_PRELOAD_COUNT)
.map((p) => fileUrl(p.storage_key));
})
.catch(() => {
// Best-effort prefetch — swallow and don't retry this chapter.
});
});
onMount(() => {
window.addEventListener('pagehide', flushFinalProgress);
});
@@ -1359,6 +1408,13 @@
</div>
{/if}
<!-- Warm the next chapter's first images once the reader nears the end of
this one. Hidden (display:none still fetches) — a pure cache primer so
flipping to the next chapter shows pages immediately. -->
{#each nextPreloadUrls as u (u)}
<img src={u} alt="" aria-hidden="true" class="preload" loading="eager" data-testid="reader-next-preload" />
{/each}
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
scroll so left/right would steal panning. Tap left/right advances
within the chapter (falling through to adjacent chapters at the
@@ -1419,6 +1475,23 @@
</ul>
</Sheet>
<Sheet
open={shortcutsOpen}
title="Keyboard shortcuts"
onClose={() => (shortcutsOpen = false)}
testid="reader-shortcuts"
>
<dl class="shortcut-list">
<div class="shortcut"><dt><kbd></kbd> / <kbd>J</kbd></dt><dd>Next page</dd></div>
<div class="shortcut"><dt><kbd></kbd> / <kbd>K</kbd></dt><dd>Previous page</dd></div>
<div class="shortcut"><dt><kbd>Home</kbd></dt><dd>First page</dd></div>
<div class="shortcut"><dt><kbd>End</kbd></dt><dd>Last page</dd></div>
<div class="shortcut"><dt><kbd></kbd> / <kbd></kbd></dt><dd>Next / previous chapter (continuous mode)</dd></div>
<div class="shortcut"><dt><kbd>Esc</kbd></dt><dd>Exit full-screen / close this overlay</dd></div>
<div class="shortcut"><dt><kbd>?</kbd></dt><dd>Show this help</dd></div>
</dl>
</Sheet>
<Sheet
open={settingsOpen}
title="Reader settings"
@@ -1783,11 +1856,23 @@
written by the ResizeObserver in onMount so the reservation
always matches actual rendered height. Focus mode collapses
the reservation in lockstep with the bar's slide-out. */
/* Sizing is width-driven: every page renders at this consistent width
(a capped reading column on desktop, full width on narrow screens)
regardless of its intrinsic dimensions. Tall pages therefore extend
downward and scroll instead of shrinking to fit the viewport height —
which is what used to turn long vertical pages into thin stripes. */
.page-wrap,
.continuous {
--reader-page-width: min(100%, 700px);
}
.page-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--space-2);
align-items: center;
/* start (not center): a tall page's top must sit under the reader
nav so the reader begins at the top and scrolls down. */
align-items: start;
padding-top: var(--reader-nav-h);
transition: padding-top 220ms ease-out;
}
@@ -1811,8 +1896,8 @@
}
.page-image {
width: var(--reader-page-width);
max-width: 100%;
max-height: 90vh;
height: auto;
margin: 0 auto;
display: block;
@@ -1829,12 +1914,10 @@
-webkit-user-select: none;
}
.continuous .page-image {
/* In continuous mode the user is scrolling — let each page take
its natural height instead of capping at viewport height, so
there are no scroll dead-zones inside a single page. */
max-height: none;
}
/* Continuous mode inherits the width-driven sizing above: every page is
the same width and takes its natural (uncapped) height, so there are
no scroll dead-zones inside a single page and widths stay consistent
between a narrow scan and a wide one. */
.nav {
display: inline-flex;
@@ -1851,6 +1934,12 @@
transition:
background var(--transition),
border-color var(--transition);
/* Now that the page-wrap grid is top-aligned, a tall page makes its
row much taller than the viewport. Stick the prev/next chevrons to
the vertical middle of the viewport so they stay reachable while
the reader scrolls down a long page. */
position: sticky;
top: calc(50vh - 22px);
}
/* ===== Continuous-mode chapter bar (sticky bottom) =====
@@ -2064,6 +2153,39 @@
-webkit-overflow-scrolling: touch;
}
.shortcut-list {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.shortcut {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.shortcut dt {
flex-shrink: 0;
}
.shortcut dd {
margin: 0;
color: var(--text-muted);
text-align: right;
}
.shortcut kbd {
font-family: var(--font-mono, monospace);
font-size: var(--font-xs);
padding: 1px 6px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
}
.chapter-jump-item {
display: flex;
align-items: center;