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:
@@ -7,8 +7,10 @@ pub mod genre;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod patch;
|
||||
pub mod read_progress;
|
||||
pub mod session;
|
||||
pub mod tag;
|
||||
pub mod upload_entry;
|
||||
pub mod user;
|
||||
pub mod user_preferences;
|
||||
|
||||
@@ -21,7 +23,9 @@ pub use genre::{Genre, GenreRef};
|
||||
pub use manga::{Manga, MangaCard, MangaDetail};
|
||||
pub use page::Page;
|
||||
pub use patch::Patch;
|
||||
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
||||
pub use session::Session;
|
||||
pub use tag::{Tag, TagRef};
|
||||
pub use upload_entry::UploadEntry;
|
||||
pub use user::User;
|
||||
pub use user_preferences::UserPreferences;
|
||||
|
||||
50
backend/src/domain/read_progress.rs
Normal file
50
backend/src/domain/read_progress.rs
Normal 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>,
|
||||
}
|
||||
40
backend/src/domain/upload_entry.rs
Normal file
40
backend/src/domain/upload_entry.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user