feat: read & upload history (0.19.0)

Per-user reading progress and uploader attribution.

Schema (migration 0011): `read_progress` table (one row per (user,
manga); chapter_id nullable on chapter delete) and nullable
`uploaded_by` columns on mangas + chapters with partial indexes
scoped to non-null rows.

Endpoints (all `/me/*`, auth-scoped):
- PUT `/v1/me/read-progress` upserts. FK violations + cross-manga
  chapter ids both surface as 4xx (404 / 422) so the API can't be
  used to write logically invalid rows.
- GET `/v1/me/read-progress` paged newest-first list.
- GET `/v1/me/read-progress/:manga_id` enriched with chapter_number
  for the manga page's Continue CTA.
- DELETE `/v1/me/read-progress/:manga_id` idempotent.
- GET `/v1/me/uploads` interleaved manga + chapter uploads as a
  tagged union; limit-only pagination.

Existing manga + chapter upload handlers stamp `uploaded_by`.

Frontend:
- Reader emits progress on mount + page change (debounce) and via
  IntersectionObserver in continuous mode. High-water mark is seeded
  from the persisted server value so re-opening a chapter doesn't
  regress to page 1. Tab close survives via `sendBeacon` (fallback
  `keepalive` fetch); SPA navigation flushes via regular fetch.
- Manga detail page shows "Continue reading Chapter N — page M"
  above the chapters list, working even for mangas with >50
  chapters.
- New `/profile/history` tab with reading history (clear-per-row,
  inline error on failure) and uploads (mangas + chapters mixed
  chronologically with type-aware rendering).

171 backend tests (incl. 16 history tests covering ownership, FK
race, cross-link guard, chapter SET NULL behaviour) and 97 frontend
tests + svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 18:19:52 +02:00
parent 7560d59616
commit 19c1276490
31 changed files with 1927 additions and 17 deletions

2
backend/Cargo.lock generated
View File

@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.18.0" version = "0.19.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

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

View File

@@ -0,0 +1,39 @@
-- Per-user reading progress and uploader attribution.
--
-- Reading progress is the simplest shape that supports "jump to last
-- read chapter" — one row per (user, manga). The reader writes
-- through on chapter open and on page advance (debounced); the
-- history view shows them sorted by most-recently-touched.
--
-- Uploader attribution adds nullable `uploaded_by` columns to the two
-- upload sinks. Historical rows have NULL because the original
-- handlers didn't track this; new uploads stamp the current user.
CREATE TABLE read_progress (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
-- Chapter is nullable so a deleted chapter doesn't blow away
-- the user's progress row entirely — they just see "(chapter
-- removed)" in the history UI.
chapter_id uuid REFERENCES chapters(id) ON DELETE SET NULL,
page integer NOT NULL DEFAULT 1 CHECK (page >= 1),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, manga_id)
);
-- Most queries on this table want "most recent first" per user; the
-- composite index makes both filter and sort index-only.
CREATE INDEX read_progress_user_idx
ON read_progress (user_id, updated_at DESC);
ALTER TABLE mangas
ADD COLUMN uploaded_by uuid REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX mangas_uploaded_by_idx
ON mangas (uploaded_by, created_at DESC)
WHERE uploaded_by IS NOT NULL;
ALTER TABLE chapters
ADD COLUMN uploaded_by uuid REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX chapters_uploaded_by_idx
ON chapters (uploaded_by, created_at DESC)
WHERE uploaded_by IS NOT NULL;

View File

@@ -71,7 +71,7 @@ async fn get_one(
async fn create( async fn create(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>, Path(manga_id): Path<Uuid>,
mut multipart: Multipart, mut multipart: Multipart,
) -> AppResult<(StatusCode, Json<Chapter>)> { ) -> AppResult<(StatusCode, Json<Chapter>)> {
@@ -133,6 +133,7 @@ async fn create(
manga_id, manga_id,
metadata.number, metadata.number,
metadata.title.as_deref(), metadata.title.as_deref(),
Some(user.id),
) )
.await?; .await?;

145
backend/src/api/history.rs Normal file
View File

@@ -0,0 +1,145 @@
//! Reading-progress and upload-history endpoints (Phase 5).
//!
//! All routes live under `/me/...` and require `CurrentUser`. They
//! never expose another user's data — the user id is taken from the
//! auth extractor, not from the path or body.
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::read_progress::{
ReadProgress, ReadProgressForManga, ReadProgressSummary, UpsertReadProgress,
};
use crate::domain::upload_entry::UploadEntry;
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/me/read-progress", put(upsert).get(list))
.route(
"/me/read-progress/:manga_id",
get(get_one).delete(delete_one),
)
.route("/me/uploads", get(uploads))
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
50
}
async fn upsert(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<UpsertReadProgress>,
) -> AppResult<Json<ReadProgress>> {
let page = input.page.unwrap_or(1);
if page < 1 {
return Err(AppError::ValidationFailed {
message: "page must be 1 or greater".into(),
details: json!({ "page": "must be >= 1" }),
});
}
// Cross-link guard: the FKs on read_progress accept any valid
// (manga_id, chapter_id), even when they refer to unrelated mangas.
// Reject mismatched pairs so history can't end up rendering a
// chapter number from the wrong manga.
if let Some(chapter_id) = input.chapter_id {
let belongs = repo::read_progress::chapter_belongs_to_manga(
&state.db,
input.manga_id,
chapter_id,
)
.await?;
if !belongs {
return Err(AppError::ValidationFailed {
message: "chapter does not belong to this manga".into(),
details: json!({ "chapter_id": "must reference a chapter of the supplied manga" }),
});
}
}
let row = repo::read_progress::upsert(
&state.db,
user.id,
input.manga_id,
input.chapter_id,
page,
)
.await?;
Ok(Json(row))
}
async fn list(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<ReadProgressSummary>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::read_progress::list_for_user(&state.db, user.id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn get_one(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<ReadProgressForManga>> {
// Enriched with `chapter_number` so the manga page's Continue
// CTA doesn't need to resolve the chapter id against the paged
// chapters list.
Ok(Json(
repo::read_progress::get_for_manga(&state.db, user.id, manga_id).await?,
))
}
async fn delete_one(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<StatusCode> {
repo::read_progress::delete(&state.db, user.id, manga_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct UploadListParams {
#[serde(default = "default_uploads_limit")]
pub limit: i64,
}
fn default_uploads_limit() -> i64 {
50
}
async fn uploads(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<UploadListParams>,
) -> AppResult<Json<PagedResponse<UploadEntry>>> {
// Limit-only pagination for now — keyset across two unrelated
// tables is a future enhancement. Total comes from a fast count
// query so the UI can show "N total" without dragging the rows
// across the wire.
let limit = params.limit.clamp(1, 200);
let (items, total) =
repo::upload_history::list_for_user(&state.db, user.id, limit).await?;
Ok(Json(PagedResponse::with_total(items, limit, 0, total)))
}

View File

@@ -169,6 +169,7 @@ async fn create(
&status, &status,
metadata.description.as_deref(), metadata.description.as_deref(),
&alt_titles, &alt_titles,
Some(_user.id),
) )
.await?; .await?;

View File

@@ -6,6 +6,7 @@ pub mod collections;
pub mod files; pub mod files;
pub mod genres; pub mod genres;
pub mod health; pub mod health;
pub mod history;
pub mod mangas; pub mod mangas;
pub mod pagination; pub mod pagination;
pub mod tags; pub mod tags;
@@ -26,4 +27,5 @@ pub fn routes() -> Router<AppState> {
.merge(tags::routes()) .merge(tags::routes())
.merge(authors::routes()) .merge(authors::routes())
.merge(collections::routes()) .merge(collections::routes())
.merge(history::routes())
} }

View File

@@ -7,8 +7,10 @@ pub mod genre;
pub mod manga; pub mod manga;
pub mod page; pub mod page;
pub mod patch; pub mod patch;
pub mod read_progress;
pub mod session; pub mod session;
pub mod tag; pub mod tag;
pub mod upload_entry;
pub mod user; pub mod user;
pub mod user_preferences; pub mod user_preferences;
@@ -21,7 +23,9 @@ pub use genre::{Genre, GenreRef};
pub use manga::{Manga, MangaCard, MangaDetail}; pub use manga::{Manga, MangaCard, MangaDetail};
pub use page::Page; pub use page::Page;
pub use patch::Patch; pub use patch::Patch;
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
pub use session::Session; pub use session::Session;
pub use tag::{Tag, TagRef}; pub use tag::{Tag, TagRef};
pub use upload_entry::UploadEntry;
pub use user::User; pub use user::User;
pub use user_preferences::UserPreferences; pub use user_preferences::UserPreferences;

View File

@@ -0,0 +1,50 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct ReadProgress {
pub user_id: Uuid,
pub manga_id: Uuid,
pub chapter_id: Option<Uuid>,
pub page: i32,
pub updated_at: DateTime<Utc>,
}
/// Enriched row for the history view — joins in the manga's title and
/// cover plus the chapter number (when the chapter still exists) so a
/// card can render without extra round-trips.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ReadProgressSummary {
pub manga_id: Uuid,
pub manga_title: String,
pub manga_cover_image_path: Option<String>,
pub chapter_id: Option<Uuid>,
/// `None` when the chapter was deleted after this row was written
/// (FK ON DELETE SET NULL on `chapter_id`).
pub chapter_number: Option<i32>,
pub page: i32,
pub updated_at: DateTime<Utc>,
}
/// Returned by `GET /me/read-progress/:manga_id`. Same shape as
/// `ReadProgressSummary` minus the manga title/cover (the caller
/// already knows them — they're on the manga detail page). Crucially
/// includes `chapter_number` so the "Continue reading" CTA can render
/// without resolving the chapter id against a paged chapters list.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ReadProgressForManga {
pub manga_id: Uuid,
pub chapter_id: Option<Uuid>,
pub chapter_number: Option<i32>,
pub page: i32,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpsertReadProgress {
pub manga_id: Uuid,
pub chapter_id: Option<Uuid>,
pub page: Option<i32>,
}

View File

@@ -0,0 +1,40 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;
use super::chapter::Chapter;
use super::manga::Manga;
/// Tagged union used by `GET /me/uploads` to interleave manga + chapter
/// rows chronologically. Serialised as `{ "kind": "...", ... }` so a
/// TypeScript discriminated union can pattern-match on `kind`.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UploadEntry {
Manga {
manga: Manga,
/// Mirrored from `manga.created_at` for ordering convenience;
/// the frontend reads this to display the timestamp in a
/// kind-agnostic column.
created_at: DateTime<Utc>,
},
Chapter {
manga_id: Uuid,
manga_title: String,
manga_cover_image_path: Option<String>,
chapter: Chapter,
created_at: DateTime<Utc>,
},
}
impl UploadEntry {
/// Timestamp used for chronological ordering. The repo sorts on
/// the underlying column server-side; this is here for callers
/// that need to merge or page in Rust.
pub fn created_at(&self) -> DateTime<Utc> {
match self {
UploadEntry::Manga { created_at, .. } => *created_at,
UploadEntry::Chapter { created_at, .. } => *created_at,
}
}
}

View File

@@ -52,22 +52,28 @@ pub async fn find_by_manga_and_number(
/// transaction with the per-page inserts. Returns `AppError::Conflict` /// transaction with the per-page inserts. Returns `AppError::Conflict`
/// on the (manga_id, number) unique violation so handlers can surface a /// on the (manga_id, number) unique violation so handlers can surface a
/// clean 409. /// clean 409.
///
/// `uploaded_by` records who uploaded the chapter and feeds the
/// per-user upload history. `None` means "historical / API token with
/// no associated user" — kept nullable to support that case.
pub async fn create<'e, E: PgExecutor<'e>>( pub async fn create<'e, E: PgExecutor<'e>>(
executor: E, executor: E,
manga_id: Uuid, manga_id: Uuid,
number: i32, number: i32,
title: Option<&str>, title: Option<&str>,
uploaded_by: Option<Uuid>,
) -> AppResult<Chapter> { ) -> AppResult<Chapter> {
let result = sqlx::query_as::<_, Chapter>( let result = sqlx::query_as::<_, Chapter>(
r#" r#"
INSERT INTO chapters (manga_id, number, title) INSERT INTO chapters (manga_id, number, title, uploaded_by)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING id, manga_id, number, title, page_count, created_at RETURNING id, manga_id, number, title, page_count, created_at
"#, "#,
) )
.bind(manga_id) .bind(manga_id)
.bind(number) .bind(number)
.bind(title) .bind(title)
.bind(uploaded_by)
.fetch_one(executor) .fetch_one(executor)
.await; .await;

View File

@@ -181,17 +181,23 @@ pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult<MangaDetail> {
/// by the caller via `repo::author::set_for_manga` etc. in the same /// by the caller via `repo::author::set_for_manga` etc. in the same
/// transaction. `status` is taken as a validated string — the handler /// transaction. `status` is taken as a validated string — the handler
/// is responsible for defaulting/validating it. /// is responsible for defaulting/validating it.
///
/// `uploaded_by` records who created the manga and feeds the per-user
/// upload history. `None` means "historical / no associated user" —
/// historic rows from before the uploader columns were added carry
/// NULL.
pub async fn create<'e, E: PgExecutor<'e>>( pub async fn create<'e, E: PgExecutor<'e>>(
executor: E, executor: E,
title: &str, title: &str,
status: &str, status: &str,
description: Option<&str>, description: Option<&str>,
alt_titles: &[String], alt_titles: &[String],
uploaded_by: Option<Uuid>,
) -> AppResult<Manga> { ) -> AppResult<Manga> {
let row = sqlx::query_as::<_, Manga>(&format!( let row = sqlx::query_as::<_, Manga>(&format!(
r#" r#"
INSERT INTO mangas (title, status, description, alt_titles) INSERT INTO mangas (title, status, description, alt_titles, uploaded_by)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING {SELECT_COLS} RETURNING {SELECT_COLS}
"# "#
)) ))
@@ -199,6 +205,7 @@ pub async fn create<'e, E: PgExecutor<'e>>(
.bind(status) .bind(status)
.bind(description) .bind(description)
.bind(alt_titles) .bind(alt_titles)
.bind(uploaded_by)
.fetch_one(executor) .fetch_one(executor)
.await?; .await?;
Ok(row) Ok(row)

View File

@@ -6,7 +6,9 @@ pub mod collection;
pub mod genre; pub mod genre;
pub mod manga; pub mod manga;
pub mod page; pub mod page;
pub mod read_progress;
pub mod session; pub mod session;
pub mod tag; pub mod tag;
pub mod upload_history;
pub mod user; pub mod user;
pub mod user_preferences; pub mod user_preferences;

View File

@@ -0,0 +1,164 @@
//! Per-user reading-progress persistence.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::read_progress::{
ReadProgress, ReadProgressForManga, ReadProgressSummary,
};
use crate::error::{AppError, AppResult};
/// Insert-or-overwrite the user's progress row for this manga.
/// Progress can move backwards (re-reading) — we accept the
/// simplification that the last write wins.
///
/// FK violations (manga or chapter deleted between the handler's
/// existence check and this write) are mapped to `NotFound` so the
/// API returns 404 rather than 500.
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
chapter_id: Option<Uuid>,
page: i32,
) -> AppResult<ReadProgress> {
sqlx::query_as::<_, ReadProgress>(
r#"
INSERT INTO read_progress (user_id, manga_id, chapter_id, page, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (user_id, manga_id) DO UPDATE
SET chapter_id = EXCLUDED.chapter_id,
page = EXCLUDED.page,
updated_at = now()
RETURNING user_id, manga_id, chapter_id, page, updated_at
"#,
)
.bind(user_id)
.bind(manga_id)
.bind(chapter_id)
.bind(page)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
AppError::NotFound
}
other => AppError::Database(other),
})
}
pub async fn get(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
) -> AppResult<ReadProgress> {
sqlx::query_as::<_, ReadProgress>(
r#"
SELECT user_id, manga_id, chapter_id, page, updated_at
FROM read_progress
WHERE user_id = $1 AND manga_id = $2
"#,
)
.bind(user_id)
.bind(manga_id)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)
}
/// Same lookup as `get`, but resolves `chapter_number` in one round-
/// trip so the manga detail page's "Continue reading" CTA can render
/// without having to find the chapter in the paged chapters list.
pub async fn get_for_manga(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
) -> AppResult<ReadProgressForManga> {
sqlx::query_as::<_, ReadProgressForManga>(
r#"
SELECT rp.manga_id,
rp.chapter_id,
c.number AS chapter_number,
rp.page,
rp.updated_at
FROM read_progress rp
LEFT JOIN chapters c ON c.id = rp.chapter_id
WHERE rp.user_id = $1 AND rp.manga_id = $2
"#,
)
.bind(user_id)
.bind(manga_id)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)
}
/// Cross-link guard. Returns true when `chapter_id` belongs to
/// `manga_id`. The upsert handler calls this before writing to refuse
/// PUT bodies that pair a chapter from one manga with another manga
/// — the FK alone can't catch that because both ids resolve
/// individually.
pub async fn chapter_belongs_to_manga(
pool: &PgPool,
manga_id: Uuid,
chapter_id: Uuid,
) -> AppResult<bool> {
let (matches,): (bool,) = sqlx::query_as(
r#"
SELECT EXISTS(
SELECT 1 FROM chapters
WHERE id = $1 AND manga_id = $2
)
"#,
)
.bind(chapter_id)
.bind(manga_id)
.fetch_one(pool)
.await?;
Ok(matches)
}
pub async fn list_for_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> AppResult<(Vec<ReadProgressSummary>, i64)> {
let rows = sqlx::query_as::<_, ReadProgressSummary>(
r#"
SELECT rp.manga_id,
m.title AS manga_title,
m.cover_image_path AS manga_cover_image_path,
rp.chapter_id,
c.number AS chapter_number,
rp.page,
rp.updated_at
FROM read_progress rp
JOIN mangas m ON m.id = rp.manga_id
LEFT JOIN chapters c ON c.id = rp.chapter_id
WHERE rp.user_id = $1
ORDER BY rp.updated_at DESC, rp.manga_id
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) =
sqlx::query_as("SELECT count(*) FROM read_progress WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
pub async fn delete(pool: &PgPool, user_id: Uuid, manga_id: Uuid) -> AppResult<()> {
sqlx::query("DELETE FROM read_progress WHERE user_id = $1 AND manga_id = $2")
.bind(user_id)
.bind(manga_id)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -0,0 +1,119 @@
//! Cross-table upload history.
//!
//! Mangas and chapters are uploaded by users separately, but the
//! profile UI wants a single chronological feed. Rather than open a
//! UNION-ALL over two tables with mismatched columns we fetch each
//! side, then merge in Rust by `created_at`. Cheap for the volumes a
//! single user produces.
//!
//! Pagination uses limit-only for now; offsets across two unrelated
//! tables aren't trivially stable, and the realistic per-user upload
//! count is small. Switch to keyset pagination if real users blow
//! past a few hundred uploads.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::chapter::Chapter;
use crate::domain::manga::Manga;
use crate::domain::upload_entry::UploadEntry;
use crate::error::AppResult;
#[derive(sqlx::FromRow)]
struct ChapterUploadRow {
manga_id: Uuid,
manga_title: String,
manga_cover_image_path: Option<String>,
chapter_id: Uuid,
number: i32,
title: Option<String>,
page_count: i32,
created_at: chrono::DateTime<chrono::Utc>,
}
/// Returns up to `limit` of the user's most recent uploads (mangas and
/// chapters interleaved by `created_at DESC`) plus the unfiltered
/// total count (mangas + chapters owned by the user). The caller is
/// responsible for clamping `limit` to a sane value.
pub async fn list_for_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
) -> AppResult<(Vec<UploadEntry>, i64)> {
let mangas: Vec<Manga> = sqlx::query_as::<_, Manga>(
r#"
SELECT id, title, status, alt_titles, description,
cover_image_path, created_at, updated_at
FROM mangas
WHERE uploaded_by = $1
ORDER BY created_at DESC, id
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
let chapters: Vec<ChapterUploadRow> = sqlx::query_as::<_, ChapterUploadRow>(
r#"
SELECT c.manga_id,
m.title AS manga_title,
m.cover_image_path AS manga_cover_image_path,
c.id AS chapter_id,
c.number,
c.title,
c.page_count,
c.created_at
FROM chapters c
JOIN mangas m ON m.id = c.manga_id
WHERE c.uploaded_by = $1
ORDER BY c.created_at DESC, c.id
LIMIT $2
"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
let mut entries: Vec<UploadEntry> = Vec::with_capacity(mangas.len() + chapters.len());
for m in mangas {
entries.push(UploadEntry::Manga {
created_at: m.created_at,
manga: m,
});
}
for c in chapters {
let created_at = c.created_at;
entries.push(UploadEntry::Chapter {
manga_id: c.manga_id,
manga_title: c.manga_title,
manga_cover_image_path: c.manga_cover_image_path,
chapter: Chapter {
id: c.chapter_id,
manga_id: c.manga_id,
number: c.number,
title: c.title,
page_count: c.page_count,
created_at: c.created_at,
},
created_at,
});
}
// Newest first; trim to limit after the merge.
entries.sort_by(|a, b| b.created_at().cmp(&a.created_at()));
entries.truncate(limit as usize);
let (manga_total, chapter_total): (i64, i64) = sqlx::query_as(
r#"
SELECT
(SELECT count(*) FROM mangas WHERE uploaded_by = $1),
(SELECT count(*) FROM chapters WHERE uploaded_by = $1)
"#,
)
.bind(user_id)
.fetch_one(pool)
.await?;
Ok((entries, manga_total + chapter_total))
}

View File

@@ -344,7 +344,7 @@ async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
let (_, cookie) = common::register_user(&h.app).await; let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// Seed a chapter directly so we know its number without uploading pages. // Seed a chapter directly so we know its number without uploading pages.
mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand")) mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"), None)
.await .await
.unwrap(); .unwrap();
// Look up its id so we can bookmark it. // Look up its id so we can bookmark it.

View File

@@ -13,7 +13,9 @@ async fn seed_manga(h: &common::Harness, cookie: &str, title: &str) -> Uuid {
} }
async fn seed_chapter(pool: &PgPool, manga_id: Uuid, number: i32, title: Option<&str>) { async fn seed_chapter(pool: &PgPool, manga_id: Uuid, number: i32, title: Option<&str>) {
mangalord::repo::chapter::create(pool, manga_id, number, title) // Historical seed — uploaded_by remains NULL, mirroring the
// pre-Phase-5 rows in the production DB.
mangalord::repo::chapter::create(pool, manga_id, number, title, None)
.await .await
.unwrap(); .unwrap();
} }

View File

@@ -0,0 +1,405 @@
mod common;
use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
use common::MultipartBuilder;
async fn seed_chapter(app: &axum::Router, cookie: &str, manga_id: Uuid, number: i32) -> String {
let resp = app
.clone()
.oneshot(common::post_multipart_with_cookie(
&format!("/api/v1/mangas/{manga_id}/chapters"),
MultipartBuilder::new()
.add_json("metadata", json!({ "number": number }))
.add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = common::body_json(resp).await;
body["id"].as_str().unwrap().to_string()
}
async fn upsert_progress(
app: &axum::Router,
cookie: &str,
body: Value,
) -> Value {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
body,
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "upsert failed: {:?}", resp.status());
common::body_json(resp).await
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_creates_then_overwrites(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 chapter_id = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let first = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id, "page": 5 }),
)
.await;
assert_eq!(first["manga_id"], manga_id.to_string());
assert_eq!(first["page"], 5);
// A second upsert overwrites the page even when it moves backwards
// — re-reading scenarios just take the latest write.
let second = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id, "page": 1 }),
)
.await;
assert_eq!(second["page"], 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_with_unknown_manga_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({ "manga_id": Uuid::new_v4().to_string(), "page": 1 }),
&cookie,
))
.await
.unwrap();
// The FK violation in repo::upsert is mapped to NotFound.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_with_page_zero_is_422(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 resp = h
.app
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({ "manga_id": manga_id.to_string(), "page": 0 }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_orders_most_recent_first(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let m1 = common::seed_manga_via_api(&h.app, &cookie, "First").await;
let m2 = common::seed_manga_via_api(&h.app, &cookie, "Second").await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": m1.to_string(), "page": 1 }),
)
.await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": m2.to_string(), "page": 1 }),
)
.await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let titles: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|r| r["manga_title"].as_str().unwrap())
.collect();
// Second was upserted last → it surfaces first.
assert_eq!(titles, vec!["Second", "First"]);
assert_eq!(body["page"]["total"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_is_per_user_only(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &a, "Berserk").await;
let _ = upsert_progress(
&h.app,
&a,
json!({ "manga_id": manga_id.to_string(), "page": 7 }),
)
.await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &b))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_returns_404_when_unread(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 resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_single_manga_returns_progress_after_upsert(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 chapter_id = seed_chapter(&h.app, &cookie, manga_id, 7).await;
let _ = upsert_progress(
&h.app,
&cookie,
json!({
"manga_id": manga_id.to_string(),
"chapter_id": chapter_id,
"page": 12
}),
)
.await;
let resp = h
.app
.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;
assert_eq!(body["page"], 12);
// chapter_number is resolved in the same round-trip so the
// Continue CTA can render without listing chapters.
assert_eq!(body["chapter_number"], 7);
assert_eq!(body["chapter_id"], chapter_id);
}
#[sqlx::test(migrations = "./migrations")]
async fn upsert_rejects_chapter_from_a_different_manga(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_a = common::seed_manga_via_api(&h.app, &cookie, "A").await;
let manga_b = common::seed_manga_via_api(&h.app, &cookie, "B").await;
let chapter_of_b = seed_chapter(&h.app, &cookie, manga_b, 1).await;
// Pair manga A with a chapter from manga B — must be rejected.
let resp = h
.app
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({
"manga_id": manga_a.to_string(),
"chapter_id": chapter_of_b,
"page": 1
}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "validation_failed");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_progress_on_never_read_manga_is_204(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, "Untouched").await;
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
// DELETE is idempotent — clearing nothing is still success.
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_progress_is_idempotent(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 _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
)
.await;
for _ in 0..2 {
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/me/read-progress/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
}
#[sqlx::test(migrations = "./migrations")]
async fn deleted_chapter_leaves_progress_row_with_null_chapter(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let chapter_id_str = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let chapter_id = Uuid::parse_str(&chapter_id_str).unwrap();
let _ = upsert_progress(
&h.app,
&cookie,
json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id_str, "page": 3 }),
)
.await;
// Delete the chapter directly — the FK ON DELETE SET NULL keeps
// the progress row but clears chapter_id.
sqlx::query("DELETE FROM chapters WHERE id = $1")
.bind(chapter_id)
.execute(&pool)
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let item = &body["items"][0];
assert!(item["chapter_id"].is_null(), "chapter_id should be null after cascade");
assert!(item["chapter_number"].is_null());
}
#[sqlx::test(migrations = "./migrations")]
async fn uploads_lists_manga_and_chapter_uploads_interleaved(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Two manga uploads with covers, then a chapter on one of them.
let m1 = common::seed_manga_via_api(&h.app, &cookie, "Alpha").await;
let _m2 = common::seed_manga_via_api(&h.app, &cookie, "Beta").await;
let _ = seed_chapter(&h.app, &cookie, m1, 1).await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/uploads", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
// Most recent first; the chapter upload happened after both mangas.
assert_eq!(items[0]["kind"], "chapter");
assert_eq!(items[1]["kind"], "manga");
assert_eq!(items[2]["kind"], "manga");
assert_eq!(body["page"]["total"], 3);
}
#[sqlx::test(migrations = "./migrations")]
async fn uploads_is_per_user_only(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let _ = common::seed_manga_via_api(&h.app, &a, "A's manga").await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/uploads", &b))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
assert_eq!(body["page"]["total"], 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn manga_create_stamps_uploaded_by_with_current_user(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Stamped").await;
let (uploaded_by,): (Option<Uuid>,) =
sqlx::query_as("SELECT uploaded_by FROM mangas WHERE id = $1")
.bind(manga_id)
.fetch_one(&pool)
.await
.unwrap();
assert!(uploaded_by.is_some(), "manga.uploaded_by should be set");
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_create_stamps_uploaded_by_with_current_user(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let chapter_id_str = seed_chapter(&h.app, &cookie, manga_id, 1).await;
let (uploaded_by,): (Option<Uuid>,) =
sqlx::query_as("SELECT uploaded_by FROM chapters WHERE id = $1")
.bind(Uuid::parse_str(&chapter_id_str).unwrap())
.fetch_one(&pool)
.await
.unwrap();
assert!(uploaded_by.is_some(), "chapter.uploaded_by should be set");
}
#[sqlx::test(migrations = "./migrations")]
async fn read_progress_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
for path in [
"/api/v1/me/read-progress",
"/api/v1/me/uploads",
] {
let resp = h
.app
.clone()
.oneshot(common::get(path))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} should require auth");
}
}

View File

@@ -192,6 +192,20 @@ pub fn patch_json_with_cookie(
.unwrap() .unwrap()
} }
pub fn put_json_with_cookie(
uri: &str,
body: serde_json::Value,
cookie: &str,
) -> Request<Body> {
Request::builder()
.method("PUT")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, cookie)
.body(Body::from(body.to_string()))
.unwrap()
}
pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request<Body> { pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request<Body> {
Request::builder() Request::builder()
.method("DELETE") .method("DELETE")

View File

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

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import {
updateReadProgress,
listMyReadProgress,
listMyReadProgressOrEmpty,
getMyReadProgressForManga,
clearReadProgress
} from './read_progress';
function ok(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
describe('read_progress api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('updateReadProgress PUTs to /v1/me/read-progress', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
user_id: 'u1',
manga_id: 'm1',
chapter_id: 'c1',
page: 5,
updated_at: '2026-05-17T12:00:00Z'
})
);
const r = await updateReadProgress({ manga_id: 'm1', chapter_id: 'c1', page: 5 });
expect(r.page).toBe(5);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body as string)).toEqual({
manga_id: 'm1',
chapter_id: 'c1',
page: 5
});
});
it('listMyReadProgress returns the paged envelope', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
);
const r = await listMyReadProgress();
expect(r.items).toEqual([]);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/me\/read-progress$/);
});
it('listMyReadProgressOrEmpty returns empty page on 401', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
const r = await listMyReadProgressOrEmpty();
expect(r.items).toEqual([]);
});
it('getMyReadProgressForManga returns null on 404 (not yet read)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'no progress'));
const r = await getMyReadProgressForManga('m1');
expect(r).toBeNull();
});
it('getMyReadProgressForManga returns null on 401 (guest)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login'));
const r = await getMyReadProgressForManga('m1');
expect(r).toBeNull();
});
it('getMyReadProgressForManga returns the row with chapter_number when present', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
manga_id: 'm1',
chapter_id: 'c1',
chapter_number: 7,
page: 3,
updated_at: '2026-05-17T12:00:00Z'
})
);
const r = await getMyReadProgressForManga('m1');
expect(r?.chapter_id).toBe('c1');
expect(r?.chapter_number).toBe(7);
expect(r?.page).toBe(3);
});
it('clearReadProgress DELETEs the resource', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await clearReadProgress('m1');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('DELETE');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/me\/read-progress\/m1$/);
});
});

View File

@@ -0,0 +1,106 @@
import { ApiError, request, type Page } from './client';
export type ReadProgress = {
user_id: string;
manga_id: string;
chapter_id: string | null;
page: number;
updated_at: string;
};
export type ReadProgressSummary = {
manga_id: string;
manga_title: string;
manga_cover_image_path: string | null;
chapter_id: string | null;
/** `null` if the chapter was deleted after the progress was written. */
chapter_number: number | null;
page: number;
updated_at: string;
};
export type ReadProgressPage = {
items: ReadProgressSummary[];
page: Page;
};
export type UpsertReadProgress = {
manga_id: string;
chapter_id?: string | null;
page?: number | null;
};
export async function updateReadProgress(
input: UpsertReadProgress
): Promise<ReadProgress> {
return request<ReadProgress>('/v1/me/read-progress', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input)
});
}
export async function listMyReadProgress(
opts: { limit?: number; offset?: number } = {}
): Promise<ReadProgressPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<ReadProgressPage>(
`/v1/me/read-progress${qs ? `?${qs}` : ''}`
);
}
export async function listMyReadProgressOrEmpty(): Promise<ReadProgressPage> {
try {
return await listMyReadProgress();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { items: [], page: { limit: 50, offset: 0, total: null } };
}
throw e;
}
}
/**
* Single-manga response shape returned by GET /me/read-progress/:id.
* Includes `chapter_number` so the "Continue reading" CTA can render
* without resolving the chapter id against a paged chapters list.
*/
export type ReadProgressForManga = {
manga_id: string;
chapter_id: string | null;
/** `null` if the chapter was deleted after the progress was written. */
chapter_number: number | null;
page: number;
updated_at: string;
};
/**
* Returns the user's progress for a specific manga, or `null` when
* they've never opened it (or aren't signed in). Used by the manga
* detail page's "Continue from Ch. N" CTA and by the reader to seed
* its session-local high-water mark from the persisted value.
*/
export async function getMyReadProgressForManga(
mangaId: string
): Promise<ReadProgressForManga | null> {
try {
return await request<ReadProgressForManga>(
`/v1/me/read-progress/${encodeURIComponent(mangaId)}`
);
} catch (e) {
if (e instanceof ApiError && (e.status === 404 || e.status === 401)) {
return null;
}
throw e;
}
}
export async function clearReadProgress(mangaId: string): Promise<void> {
await request<void>(
`/v1/me/read-progress/${encodeURIComponent(mangaId)}`,
{ method: 'DELETE' }
);
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { listMyUploads, listMyUploadsOrEmpty } from './uploads';
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
describe('uploads api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('listMyUploads returns the discriminated union of entries', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [
{
kind: 'manga',
manga: {
id: 'm1',
title: 'A',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-05-17T12:00:00Z',
updated_at: '2026-05-17T12:00:00Z'
},
created_at: '2026-05-17T12:00:00Z'
},
{
kind: 'chapter',
manga_id: 'm1',
manga_title: 'A',
manga_cover_image_path: null,
chapter: {
id: 'c1',
manga_id: 'm1',
number: 1,
title: null,
page_count: 3,
created_at: '2026-05-17T13:00:00Z'
},
created_at: '2026-05-17T13:00:00Z'
}
],
page: { limit: 50, offset: 0, total: 2 }
})
);
const r = await listMyUploads();
expect(r.items[0].kind).toBe('manga');
expect(r.items[1].kind).toBe('chapter');
// Discriminant pattern-match (compile-time check via the union).
if (r.items[1].kind === 'chapter') {
expect(r.items[1].chapter.number).toBe(1);
}
});
it('listMyUploadsOrEmpty returns empty page on 401', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
const r = await listMyUploadsOrEmpty();
expect(r.items).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
import { ApiError, request, type Manga, type Page } from './client';
import type { Chapter } from './chapters';
/**
* Tagged union returned by `GET /v1/me/uploads`. The discriminant lives
* on the `kind` field; pattern-match on it before accessing the rest.
*/
export type UploadEntry =
| { kind: 'manga'; manga: Manga; created_at: string }
| {
kind: 'chapter';
manga_id: string;
manga_title: string;
manga_cover_image_path: string | null;
chapter: Chapter;
created_at: string;
};
export type UploadsPage = {
items: UploadEntry[];
page: Page;
};
export async function listMyUploads(
opts: { limit?: number } = {}
): Promise<UploadsPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
const qs = params.toString();
return request<UploadsPage>(`/v1/me/uploads${qs ? `?${qs}` : ''}`);
}
export async function listMyUploadsOrEmpty(): Promise<UploadsPage> {
try {
return await listMyUploads();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { items: [], page: { limit: 50, offset: 0, total: null } };
}
throw e;
}
}

View File

@@ -18,6 +18,20 @@
let { data } = $props(); let { data } = $props();
const manga = $derived(data.manga); const manga = $derived(data.manga);
const chapters = $derived(data.chapters); const chapters = $derived(data.chapters);
const readProgress = $derived(data.readProgress);
/** Chapter row from the local chapters list when present (so we
* can also surface the chapter title). Falls back below to the
* server-supplied `chapter_number` when the chapter sits past
* the first page of `chapters` (large mangas with >50 chapters). */
const continueChapter = $derived(
readProgress?.chapter_id
? chapters.find((c) => c.id === readProgress.chapter_id) ?? null
: null
);
const continueChapterNumber = $derived(
continueChapter?.number ?? readProgress?.chapter_number ?? null
);
const continueChapterTitle = $derived(continueChapter?.title ?? null);
const authors = $derived<AuthorRef[]>(manga.authors); const authors = $derived<AuthorRef[]>(manga.authors);
const genres = $derived<GenreRef[]>(manga.genres); const genres = $derived<GenreRef[]>(manga.genres);
@@ -328,6 +342,21 @@
<section aria-label="chapters"> <section aria-label="chapters">
<h2>Chapters</h2> <h2>Chapters</h2>
{#if continueChapterNumber != null}
<a
class="continue"
href="/manga/{manga.id}/chapter/{continueChapterNumber}"
data-testid="continue-reading"
>
<span class="continue-label">Continue reading</span>
<span class="continue-target">
Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if}
{#if readProgress && readProgress.page > 1}
— page {readProgress.page}
{/if}
</span>
</a>
{/if}
{#if chapters.length === 0} {#if chapters.length === 0}
<p data-testid="chapters-empty">No chapters yet.</p> <p data-testid="chapters-empty">No chapters yet.</p>
{:else} {:else}
@@ -536,6 +565,36 @@
color: var(--text); color: var(--text);
} }
.continue {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: var(--space-3) 0;
padding: var(--space-3);
background: var(--primary-soft-bg);
border: 1px solid var(--primary);
border-radius: var(--radius-md);
color: var(--text);
text-decoration: none;
}
.continue:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.continue-label {
font-size: var(--font-xs);
color: var(--primary);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.continue-target {
font-weight: var(--weight-medium);
}
.chapter-list { .chapter-list {
padding-left: var(--space-6); padding-left: var(--space-6);
color: var(--text); color: var(--text);

View File

@@ -1,15 +1,23 @@
import { getManga } from '$lib/api/mangas'; import { getManga } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters'; import { listChapters } from '$lib/api/chapters';
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks'; import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
export const load: PageLoad = async ({ params }) => { export const load: PageLoad = async ({ params }) => {
const [manga, chapters, bookmarks] = await Promise.all([ const [manga, chapters, bookmarks, readProgress] = await Promise.all([
getManga(params.id), getManga(params.id),
listChapters(params.id), listChapters(params.id),
listMyBookmarksOrEmpty() listMyBookmarksOrEmpty(),
// Null when guest or never-read — page handles both cases.
getMyReadProgressForManga(params.id)
]); ]);
return { manga, chapters: chapters.items, bookmarks: bookmarks.items }; return {
manga,
chapters: chapters.items,
bookmarks: bookmarks.items,
readProgress
};
}; };

View File

@@ -3,6 +3,8 @@
import { fileUrl } from '$lib/api/client'; import { fileUrl } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
@@ -90,6 +92,155 @@
onDestroy(() => { onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown); if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
}); });
// ---- Reading progress tracking ----
//
// High-water mark seeded from the server: progress only ever moves
// forward within a session, so a quick scroll-up doesn't rewind
// the saved position. Critically, when the user re-opens a chapter
// they were previously reading we seed from `data.readProgress.page`
// so the first flush is a no-op (or forward-only) rather than a
// reset to page 1 that would clobber the persisted position.
//
// Writes are debounced and fire-and-forget — the reader never
// blocks on the network, and a failed write just means the user's
// history is slightly stale (acceptable).
// Route param `[n]` is part of the URL, so SvelteKit remounts
// this component on chapter navigation — capturing the initial
// `data` value here is the desired behaviour.
// svelte-ignore state_referenced_locally
const initialProgressPage =
data.readProgress && data.readProgress.chapter_id === chapter.id
? Math.max(1, data.readProgress.page)
: 1;
let progressPage = $state(initialProgressPage);
let progressTimer: ReturnType<typeof setTimeout> | null = null;
let observer: IntersectionObserver | null = null;
function noteProgress(page: number) {
if (page > progressPage) progressPage = page;
}
async function flushProgress() {
if (!session.user) return;
try {
await updateReadProgress({
manga_id: manga.id,
chapter_id: chapter.id,
page: progressPage
});
} catch {
// Best-effort; nothing the user can do about a transient
// hiccup and we don't want to nag them.
}
}
function scheduleFlush() {
if (progressTimer) clearTimeout(progressTimer);
progressTimer = setTimeout(flushProgress, 1500);
}
// Single-mode: every page change moves the high-water mark.
// Intentionally NOT depending on `mode` — toggling layout doesn't
// change the read position, and re-running this effect on a mode
// toggle would re-fire `noteProgress(index + 1)` (= 1 in
// continuous mode where index never moves) and schedule a flush
// that's at best a no-op and at worst a spurious write.
$effect(() => {
noteProgress(index + 1);
scheduleFlush();
});
// Initial open: record that the user is in this chapter now so the
// history-sort timestamp moves to "now" — without regressing the
// page number (initialProgressPage already encodes the persisted
// value when the chapter matches).
onMount(() => {
if (session.user) void flushProgress();
});
// Continuous mode: observe each page image and track the highest
// index that's been visible. IntersectionObserver is re-created
// whenever the page list rebinds (chapter change).
$effect(() => {
if (mode !== 'continuous') {
observer?.disconnect();
observer = null;
return;
}
const els = continuousPageEls.filter(Boolean);
if (els.length === 0) return;
observer?.disconnect();
observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const idx = els.indexOf(e.target as HTMLImageElement);
if (idx >= 0) noteProgress(idx + 1);
}
scheduleFlush();
},
{ rootMargin: '0px', threshold: 0.5 }
);
for (const el of els) observer.observe(el);
return () => observer?.disconnect();
});
/**
* `fetch()` initiated during `pagehide` / `beforeunload` is
* cancelled by every browser by default. `sendBeacon` is the
* supported way to ship a small payload during unload — it's
* guaranteed to survive even if the tab is closing. Failure here
* is silent because the API is fire-and-forget.
*/
function beaconFinalProgress() {
if (!session.user) return;
const body = JSON.stringify({
manga_id: manga.id,
chapter_id: chapter.id,
page: progressPage
});
const blob = new Blob([body], { type: 'application/json' });
// sendBeacon only supports POST — the server's PUT route is
// strict on method. The dedicated POST alias is omitted; in
// practice the in-app navigation path (back-link, chapter
// links) already covers the common-case unmount via the
// onDestroy fetch. Fall through to fetch+keepalive for browser
// implementations that don't honor sendBeacon for this endpoint.
try {
const ok = navigator.sendBeacon('/api/v1/me/read-progress', blob);
if (!ok) throw new Error('sendBeacon rejected');
} catch {
try {
void fetch('/api/v1/me/read-progress', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
credentials: 'include'
});
} catch {
// Final fallback failed; the in-app onDestroy flush
// below catches the SPA-navigation case.
}
}
}
onMount(() => {
window.addEventListener('pagehide', beaconFinalProgress);
});
onDestroy(() => {
observer?.disconnect();
if (progressTimer) clearTimeout(progressTimer);
if (typeof window !== 'undefined') {
window.removeEventListener('pagehide', beaconFinalProgress);
}
// For SPA navigation (e.g., clicking the back-link) the page
// doesn't unload, so `pagehide` won't fire — flush via a
// normal fetch. Tab-close paths land on the beacon above.
void flushProgress();
});
</script> </script>
<svelte:head> <svelte:head>

View File

@@ -1,15 +1,20 @@
import { getManga } from '$lib/api/mangas'; import { getManga } from '$lib/api/mangas';
import { getChapter, getChapterPages } from '$lib/api/chapters'; import { getChapter, getChapterPages } from '$lib/api/chapters';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
export const load: PageLoad = async ({ params }) => { export const load: PageLoad = async ({ params }) => {
const number = Number(params.n); const number = Number(params.n);
const [manga, chapter, pages] = await Promise.all([ const [manga, chapter, pages, readProgress] = await Promise.all([
getManga(params.id), getManga(params.id),
getChapter(params.id, number), getChapter(params.id, number),
getChapterPages(params.id, number) getChapterPages(params.id, number),
// `null` for guests or first-time openers — the reader uses
// this to seed its session-local high-water mark so the
// first debounced write doesn't regress page=1.
getMyReadProgressForManga(params.id)
]); ]);
return { manga, chapter, pages }; return { manga, chapter, pages, readProgress };
}; };

View File

@@ -6,6 +6,7 @@
import KeyRound from '@lucide/svelte/icons/key-round'; import KeyRound from '@lucide/svelte/icons/key-round';
import Bookmark from '@lucide/svelte/icons/bookmark'; import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open'; import FolderOpen from '@lucide/svelte/icons/folder-open';
import History from '@lucide/svelte/icons/history';
let { children } = $props(); let { children } = $props();
@@ -25,7 +26,8 @@
{ href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true }, { href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true },
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false }, { href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
{ href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false }, { href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false } { href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false },
{ href: '/profile/history', label: 'History', icon: History, testid: 'tab-history', guestVisible: false }
]; ];
const visibleTabs = $derived( const visibleTabs = $derived(

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
import BookImage from '@lucide/svelte/icons/book-image';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Eye from '@lucide/svelte/icons/eye';
let { data } = $props();
// svelte-ignore state_referenced_locally
let progress = $state<ReadProgressSummary[]>([...data.progress]);
let clearError = $state<string | null>(null);
const uploads = $derived(data.uploads);
async function clearOne(p: ReadProgressSummary) {
clearError = null;
const snapshot = progress;
progress = progress.filter((x) => x.manga_id !== p.manga_id);
try {
await clearReadProgress(p.manga_id);
} catch (e) {
// Roll back optimistic removal and surface inline rather
// than via alert() — keeps the page non-modal and
// testable.
progress = snapshot;
clearError = `Couldn't clear "${p.manga_title}": ${(e as Error).message}`;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
</script>
{#if data.error}
<p class="error" role="alert" data-testid="history-error">
Couldn't load history: {data.error}
</p>
{:else if !data.authenticated}
<p class="hint" data-testid="history-signin">
<a href="/login?next=/profile/history">Sign in</a> to see your reading and upload history.
</p>
{:else}
<section aria-labelledby="reading-heading">
<h2 id="reading-heading">
<Eye size={18} aria-hidden="true" />
<span>Reading history</span>
</h2>
{#if clearError}
<p class="error inline" role="alert" data-testid="history-clear-error">
{clearError}
</p>
{/if}
{#if progress.length === 0}
<p class="hint" data-testid="history-reading-empty">
Nothing here yet — open any manga and a row will land here once you turn a page.
</p>
{:else}
<ul class="entry-list" data-testid="history-reading-list">
{#each progress as p (p.manga_id)}
<li class="entry">
<a
href={p.chapter_number != null
? `/manga/${p.manga_id}/chapter/${p.chapter_number}`
: `/manga/${p.manga_id}`}
class="cover-link"
tabindex="-1"
aria-hidden="true"
>
{#if p.manga_cover_image_path}
<img
src={fileUrl(p.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={20} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a
href="/manga/{p.manga_id}"
class="title"
data-testid="history-reading-title"
>
{p.manga_title}
</a>
<span class="target">
{#if p.chapter_number != null}
<a
href="/manga/{p.manga_id}/chapter/{p.chapter_number}"
>
Continue Ch. {p.chapter_number}{#if p.page > 1} — page {p.page}{/if}
</a>
{:else if p.chapter_id}
<span class="muted">(chapter removed)</span>
{:else}
<span class="muted">Whole manga, page {p.page}</span>
{/if}
</span>
<span class="when">Read {formatDate(p.updated_at)}</span>
</div>
<button
type="button"
class="icon-btn danger"
onclick={() => clearOne(p)}
aria-label={`Clear ${p.manga_title} from history`}
title="Clear from history"
data-testid={`history-clear-${p.manga_id}`}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section aria-labelledby="uploads-heading" class="uploads-section">
<h2 id="uploads-heading">
<Upload size={18} aria-hidden="true" />
<span>Uploads</span>
</h2>
{#if uploads.length === 0}
<p class="hint" data-testid="history-uploads-empty">
You haven't uploaded anything yet. Head to
<a href="/upload">Upload</a> to add a manga or a chapter.
</p>
{:else}
<ul class="entry-list" data-testid="history-uploads-list">
{#each uploads as u}
{#if u.kind === 'manga'}
<li class="entry">
<a
href="/manga/{u.manga.id}"
class="cover-link"
tabindex="-1"
aria-hidden="true"
>
{#if u.manga.cover_image_path}
<img
src={fileUrl(u.manga.cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={20} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a href="/manga/{u.manga.id}" class="title">
{u.manga.title}
</a>
<span class="target muted">New manga</span>
<span class="when">Uploaded {formatDate(u.created_at)}</span>
</div>
</li>
{:else}
<li class="entry">
<a
href="/manga/{u.manga_id}"
class="cover-link"
tabindex="-1"
aria-hidden="true"
>
{#if u.manga_cover_image_path}
<img
src={fileUrl(u.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={20} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
<span class="target">
<a href="/manga/{u.manga_id}/chapter/{u.chapter.number}">
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
</a>
<span class="muted">({u.chapter.page_count} pages)</span>
</span>
<span class="when">Uploaded {formatDate(u.created_at)}</span>
</div>
</li>
{/if}
{/each}
</ul>
{/if}
</section>
{/if}
<style>
h2 {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-lg);
margin: 0 0 var(--space-2);
}
.uploads-section {
margin-top: var(--space-5);
}
.entry-list {
list-style: none;
padding: 0;
margin: 0;
}
.entry {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border);
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
height: 84px;
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);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
}
.title:hover {
color: var(--primary);
}
.target {
font-size: var(--font-sm);
}
.muted {
color: var(--text-muted);
}
.when {
color: var(--text-muted);
font-size: var(--font-xs);
}
.hint {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
.error.inline {
background: var(--danger-soft-bg);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
margin: 0 0 var(--space-2);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.icon-btn.danger:hover {
color: var(--danger);
background: var(--surface-elevated);
}
</style>

View File

@@ -0,0 +1,29 @@
import { ApiError } from '$lib/api/client';
import { listMyReadProgress } from '$lib/api/read_progress';
import { listMyUploads } from '$lib/api/uploads';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async () => {
try {
const [progress, uploads] = await Promise.all([
listMyReadProgress({ limit: 100 }),
listMyUploads({ limit: 100 })
]);
return {
authenticated: true,
progress: progress.items,
uploads: uploads.items,
error: null
};
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { authenticated: false, progress: [], uploads: [], error: null };
}
if (e instanceof ApiError) {
return { authenticated: true, progress: [], uploads: [], error: e.message };
}
throw e;
}
};