Compare commits
12 Commits
38146b4d03
...
9c4a93c058
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4a93c058 | ||
|
|
bfaa166e0a | ||
|
|
3824eaafb2 | ||
|
|
f1349100d7 | ||
|
|
3f1a5e9c41 | ||
|
|
c212deb7b0 | ||
|
|
f5692ea109 | ||
|
|
ad1689b818 | ||
|
|
01d18e7ba2 | ||
|
|
2267a83f6c | ||
|
|
f318c3bf51 | ||
|
|
cf8971faae |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.94.0"
|
||||
version = "0.105.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.94.0"
|
||||
version = "0.105.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
60
frontend/e2e/chip-catalog-links.spec.ts
Normal file
60
frontend/e2e/chip-catalog-links.spec.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
124
frontend/e2e/continue-reading-shelf.spec.ts
Normal file
124
frontend/e2e/continue-reading-shelf.spec.ts
Normal 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);
|
||||
});
|
||||
88
frontend/e2e/detail-read-markers.spec.ts
Normal file
88
frontend/e2e/detail-read-markers.spec.ts
Normal 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);
|
||||
});
|
||||
66
frontend/e2e/overflow-tooltip.spec.ts
Normal file
66
frontend/e2e/overflow-tooltip.spec.ts
Normal 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');
|
||||
});
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
86
frontend/e2e/reader-next-preload.spec.ts
Normal file
86
frontend/e2e/reader-next-preload.spec.ts
Normal 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);
|
||||
});
|
||||
149
frontend/e2e/reader-page-sizing.spec.ts
Normal file
149
frontend/e2e/reader-page-sizing.spec.ts
Normal 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);
|
||||
});
|
||||
61
frontend/e2e/reader-shortcuts.spec.ts
Normal file
61
frontend/e2e/reader-shortcuts.spec.ts
Normal 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();
|
||||
});
|
||||
103
frontend/e2e/reader-swipe.spec.ts
Normal file
103
frontend/e2e/reader-swipe.spec.ts
Normal 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/);
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.94.0",
|
||||
"version": "0.105.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
60
frontend/src/lib/actions/overflowTooltip.test.ts
Normal file
60
frontend/src/lib/actions/overflowTooltip.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/actions/overflowTooltip.ts
Normal file
45
frontend/src/lib/actions/overflowTooltip.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
17
frontend/src/lib/chapterProgress.test.ts
Normal file
17
frontend/src/lib/chapterProgress.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
17
frontend/src/lib/chapterProgress.ts
Normal file
17
frontend/src/lib/chapterProgress.ts
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
169
frontend/src/lib/components/ContinueReadingShelf.svelte
Normal file
169
frontend/src/lib/components/ContinueReadingShelf.svelte
Normal 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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
32
frontend/src/lib/continueReading.test.ts
Normal file
32
frontend/src/lib/continueReading.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/continueReading.ts
Normal file
20
frontend/src/lib/continueReading.ts
Normal 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
|
||||
);
|
||||
}
|
||||
20
frontend/src/lib/swipe.test.ts
Normal file
20
frontend/src/lib/swipe.test.ts
Normal 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
23
frontend/src/lib/swipe.ts
Normal 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';
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user