Compare commits

..

10 Commits

Author SHA1 Message Date
MechaCat02
89b8785a40 bugfix: reader-nav is fully fixed; no settle-on-scroll (0.21.3) 2026-05-17 20:57:05 +02:00
MechaCat02
64ccc0ba84 bugfix: measure bar heights with ResizeObserver instead of magic numbers (0.21.2) 2026-05-17 20:47:32 +02:00
MechaCat02
215325ad2f bugfix: reader nav sticks under the app header instead of behind it (0.21.1)
$(top offset was 44px (header's 60px minus var(--space-4)), placing the bar inside the layout header. Now sticks at var(--app-header-h).)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:42:38 +02:00
MechaCat02
7aa6e7e6d9 feat: chapter chevrons, sticky app frame, and focus mode (0.21.0)
Reader gets chapter-aware chevrons + a persistent app frame +
distraction-free focus mode.

- Single-mode chevrons (and ArrowLeft/Right + j/k) advance pages
  within the chapter and fall through to the adjacent chapter at the
  boundaries. Last page of last chapter / first page of first
  chapter disables the chevron and silent-no-ops on the keypress.
- Continuous-mode gets a fixed bottom bar with prev/next chapter
  buttons; arrows + j/k jump chapters directly.
- `?page=N` and `?page=last` URL query lets the prev-chapter jump
  land on the previous chapter's last page.
- Layout header is fixed at the top; reader nav is sticky just
  below it; both stay visible while scrolling so reading settings
  are always reachable.
- New "Focus" toggle in the reader nav hides the layout header,
  reader nav, and bottom chapter bar with smooth 220ms slide
  animations. Exit via Esc or a small floating Minimize2 button at
  the top-right (low resting opacity, full on hover). Reset on
  reader unmount so it doesn't leak to other pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:39:03 +02:00
MechaCat02
c95c1805df feat: upload flow revamp (0.20.0)
- `/upload` is now manga-only with optional N initial chapters
  staged inline.
- Additional chapters from a new `/manga/[id]/upload-chapter` route,
  reached via an "Upload chapter" button on the manga page.
- New `ChapterPagesEditor` component: thumbnails next to each row,
  click-to-preview-modal, drag-drop + reorder.
- Pages renamed to `page-NNN.<ext>` before multipart submission;
  original filenames shown as dimmed reference text during upload
  and dropped on submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:59:22 +02:00
MechaCat02
21f44cea3f bugfix: GET /me/bookmarks returns total count (0.19.2)
The profile overview's bookmark counter showed 0 even when the user had bookmarks because /me/bookmarks left page.total null. Repo now returns the count alongside the rows; handler uses with_total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:41:27 +02:00
MechaCat02
58e637085d bugfix: don't JSON.parse empty 200/201 bodies (0.19.1)
$(addMangaToCollection crashed when the backend returned 201/200 with no body — the shared client only short-circuited 204. Now any empty body returns undefined.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:30:39 +02:00
MechaCat02
19c1276490 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>
2026-05-17 18:19:52 +02:00
MechaCat02
7560d59616 feat: /profile dashboard with tabbed preferences, account, bookmarks, collections (0.18.0)
Tabbed user dashboard at `/profile` that absorbs `/settings` and
surfaces bookmarks + collections in one place.

- New `/profile` shell with tabs: Overview (counts), Preferences
  (theme + reader prefs, ported from /settings; works for guests
  via localStorage), Account (password change; auth-gated),
  Bookmarks, Collections. Guest tab list is filtered to what they
  can actually use.
- `/settings` is a 308 redirect to `/profile/preferences` so old
  bookmarks land cleanly. The "Settings" link in the top nav is
  replaced by a Profile link between Upload and Bookmarks; Bookmarks
  + Collections stay as shortcuts per the user spec.
- Extracts `lib/components/BookmarkList.svelte` and
  `lib/components/CollectionsGrid.svelte` so the top-level
  /bookmarks + /collections routes and the new profile tabs render
  the same UI without duplication. Both layers use a three-state
  load (authenticated / guest / error) to handle network hiccups
  inline.
- Deep links preserved via `?next=` on every sign-in CTA.

88 frontend unit tests + svelte-check clean; 12 of 12 e2e tests in
profile.spec.ts and reader-mode.spec.ts pass (8 other e2e failures
predate this branch and stay flagged for cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:59:29 +02:00
MechaCat02
274cc819ca feat: manga collections (0.17.0)
User-owned named lists of mangas with an add-to-collection modal on
the manga page and dedicated /collections and /collections/:id pages.

- Schema (0010): `collections` (per-user case-insensitive name
  uniqueness) + `collection_mangas` join with cascade FKs.
- Endpoints: full CRUD on `/v1/collections`, idempotent add/remove
  for `/v1/collections/:id/mangas`, and `/v1/mangas/:id/my-collections`
  for the modal's pre-checked state. Owner-mismatch surfaces as 404
  (not 403) so the API doesn't disclose collection existence to
  non-owners; the frontend funnels 401 to /login. Three-state PATCH
  via a new shared `domain::patch::Patch<T>` lets clients distinguish
  "leave alone", "clear", and "set" for description.
- Frontend: reusable `Modal` component (focus trap, opt-in
  backdrop close, ESC) and `AddToCollectionModal` with optimistic
  toggling that's race-safe under fast clicks. /collections page
  renders cover-collage cards; /collections/:id is editable with
  per-card remove. Top nav gets a Collections link.

155 backend tests (incl. 21 collection tests covering ownership,
idempotence, sample-cover enrichment, three-state PATCH, FK race);
88 frontend tests; svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:43:06 +02:00
76 changed files with 7419 additions and 1181 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -0,0 +1,31 @@
-- User-owned manga collections. Each user can curate any number of
-- named lists (e.g., "Favorites", "Reading list"); mangas can belong
-- to many collections of many users without restriction.
CREATE TABLE collections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Per-user case-insensitive name uniqueness so "Favorites" and
-- "favorites" don't both end up in someone's sidebar.
CREATE UNIQUE INDEX collections_user_name_lower_uniq
ON collections (user_id, lower(name));
CREATE INDEX collections_user_idx ON collections (user_id, created_at DESC);
CREATE TABLE collection_mangas (
collection_id uuid NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (collection_id, manga_id)
);
-- Reverse lookup: which collections contain this manga? Used by the
-- "Add to collection" modal to pre-check the boxes for the user's
-- collections this manga is already in.
CREATE INDEX collection_mangas_manga_idx ON collection_mangas (manga_id);

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

@@ -111,6 +111,7 @@ async fn list_me(
) -> AppResult<Json<PagedResponse<BookmarkSummary>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let items = repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?;
Ok(Json(PagedResponse::new(items, limit, offset)))
let (items, total) =
repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}

View File

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

View File

@@ -0,0 +1,247 @@
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::collection::{
Collection, CollectionPatch, CollectionSummary, NewCollection,
};
use crate::domain::manga::Manga;
use crate::domain::patch::Patch;
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/collections", post(create))
.route("/me/collections", get(list_mine))
.route("/collections/:id", get(get_one).patch(update).delete(delete_one))
.route("/collections/:id/mangas", get(list_mangas).post(add_manga))
.route(
"/collections/:id/mangas/:manga_id",
delete(remove_manga),
)
.route(
"/mangas/:id/my-collections",
get(list_my_collections_containing),
)
}
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
const DEFAULT_LIMIT: i64 = 50;
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
DEFAULT_LIMIT
}
#[derive(Debug, Deserialize)]
pub struct AddMangaBody {
pub manga_id: Uuid,
}
#[derive(Debug, Serialize)]
pub struct MangaCollectionIds {
pub collection_ids: Vec<Uuid>,
}
fn validate_name(name: &str) -> AppResult<()> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::ValidationFailed {
message: "name is required".into(),
details: json!({ "name": "required" }),
});
}
if trimmed.chars().count() > MAX_NAME_LEN {
return Err(AppError::ValidationFailed {
message: "name too long".into(),
details: json!({ "name": format!("max {MAX_NAME_LEN} characters") }),
});
}
Ok(())
}
fn validate_description(desc: Option<&str>) -> AppResult<()> {
if let Some(d) = desc {
if d.chars().count() > MAX_DESCRIPTION_LEN {
return Err(AppError::ValidationFailed {
message: "description too long".into(),
details: json!({ "description": format!("max {MAX_DESCRIPTION_LEN} characters") }),
});
}
}
Ok(())
}
async fn create(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<NewCollection>,
) -> AppResult<(StatusCode, Json<Collection>)> {
validate_name(&input.name)?;
validate_description(input.description.as_deref())?;
let row = repo::collection::create(
&state.db,
user.id,
&input.name,
input.description.as_deref(),
)
.await?;
Ok((StatusCode::CREATED, Json(row)))
}
async fn list_mine(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<CollectionSummary>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::collection::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(id): Path<Uuid>,
) -> AppResult<Json<Collection>> {
let row = require_owner(&state, user.id, id).await?;
Ok(Json(row))
}
async fn update(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Json(patch): Json<CollectionPatch>,
) -> AppResult<Json<Collection>> {
require_owner_id(&state, user.id, id).await?;
if let Some(ref n) = patch.name {
validate_name(n)?;
}
if let Patch::Set(ref d) = patch.description {
validate_description(Some(d.as_str()))?;
}
// Three-state semantics via `Patch<T>`: omitted → Unchanged
// (column untouched), explicit `null` → Clear (NULL), value → Set.
let description_provided = patch.description.is_provided();
let description_value: Option<&str> = match &patch.description {
Patch::Set(s) => Some(s.as_str()),
Patch::Clear | Patch::Unchanged => None,
};
let updated = repo::collection::update(
&state.db,
id,
patch.name.as_deref(),
description_provided,
description_value,
)
.await?;
Ok(Json(updated))
}
async fn delete_one(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, id).await?;
repo::collection::delete(&state.db, id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_mangas(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<Manga>>> {
require_owner_id(&state, user.id, id).await?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::collection::list_mangas(&state.db, id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn add_manga(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Json(body): Json<AddMangaBody>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, id).await?;
if !repo::manga::exists(&state.db, body.manga_id).await? {
return Err(AppError::NotFound);
}
let created = repo::collection::add_manga(&state.db, id, body.manga_id).await?;
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}
async fn remove_manga(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((collection_id, manga_id)): Path<(Uuid, Uuid)>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, collection_id).await?;
repo::collection::remove_manga(&state.db, collection_id, manga_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_my_collections_containing(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaCollectionIds>> {
// No 404 if the manga doesn't exist — the empty list is the
// correct answer ("you have it in zero of your collections") and
// keeps the request side-effect-free.
let ids =
repo::collection::list_collections_containing(&state.db, user.id, manga_id).await?;
Ok(Json(MangaCollectionIds { collection_ids: ids }))
}
/// Returns the row iff the caller owns it. Both "doesn't exist" and
/// "exists but belongs to someone else" surface as `NotFound` so the
/// API doesn't disclose collection existence to non-owners — the
/// frontend already does this funnelling for URLs, and consistency at
/// the API matters because the same identifiers travel through bots
/// and shared links.
async fn require_owner(
state: &AppState,
user_id: Uuid,
id: Uuid,
) -> AppResult<Collection> {
match repo::collection::get(&state.db, id).await {
Ok(row) if row.user_id == user_id => Ok(row),
// Either the row doesn't exist (NotFound from `get`) or it
// belongs to someone else — both collapse to NotFound.
Ok(_) | Err(AppError::NotFound) => Err(AppError::NotFound),
Err(other) => Err(other),
}
}
async fn require_owner_id(state: &AppState, user_id: Uuid, id: Uuid) -> AppResult<()> {
match repo::collection::find_owner(&state.db, id).await? {
Some(owner) if owner == user_id => Ok(()),
// Same non-leakage rationale as `require_owner` above.
_ => Err(AppError::NotFound),
}
}

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

@@ -9,7 +9,8 @@ use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga, Patch};
use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga};
use crate::domain::patch::Patch;
use crate::domain::tag::TagRef;
use crate::error::{AppError, AppResult};
use crate::repo;
@@ -168,6 +169,7 @@ async fn create(
&status,
metadata.description.as_deref(),
&alt_titles,
Some(_user.id),
)
.await?;

View File

@@ -2,9 +2,11 @@ pub mod auth;
pub mod authors;
pub mod bookmarks;
pub mod chapters;
pub mod collections;
pub mod files;
pub mod genres;
pub mod health;
pub mod history;
pub mod mangas;
pub mod pagination;
pub mod tags;
@@ -24,4 +26,6 @@ pub fn routes() -> Router<AppState> {
.merge(genres::routes())
.merge(tags::routes())
.merge(authors::routes())
.merge(collections::routes())
.merge(history::routes())
}

View File

@@ -0,0 +1,50 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use super::patch::Patch;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Collection {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Shape returned by `GET /me/collections`. Enriched with the manga
/// count and up to three sample cover paths so a collection card can
/// render without extra round-trips.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct CollectionSummary {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub manga_count: i64,
/// Cover image keys of up to three sample mangas (newest-added
/// first). `Vec<String>` rather than `Option<...>` so an empty
/// collection renders as `[]` rather than `null`.
pub sample_covers: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewCollection {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CollectionPatch {
pub name: Option<String>,
/// Three-state: missing key leaves description alone; explicit
/// `null` clears it; a string sets it. See `Patch`.
#[serde(default)]
pub description: Patch<String>,
}

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use super::author::AuthorRef;
use super::genre::GenreRef;
use super::patch::Patch;
use super::tag::TagRef;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
@@ -73,82 +74,6 @@ pub struct MangaPatch {
pub genre_ids: Option<Vec<Uuid>>,
}
/// Three-state container for nullable PATCH fields.
///
/// `serde`'s default behaviour collapses both "field missing" and
/// "field is `null`" to `Option::None`, which means an `Option<T>`
/// patch field can't distinguish "leave alone" from "set to NULL".
/// `Patch<T>` carries that distinction by deserializing JSON `null`
/// into `Clear` and any value into `Set`; with `#[serde(default)]` on
/// the field, a missing key falls through to `Unchanged`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Patch<T> {
/// Field absent from the request — leave the column untouched.
#[default]
Unchanged,
/// Field present and explicitly `null` — set the column to NULL.
Clear,
/// Field present with a value — set the column to that value.
Set(T),
}
impl<T> Patch<T> {
/// Whether the request indicated this field should be written
/// (either to a new value or to NULL).
pub fn is_provided(&self) -> bool {
!matches!(self, Patch::Unchanged)
}
/// The value to bind when writing, or `None` for `Unchanged`/`Clear`.
pub fn set_value(&self) -> Option<&T> {
match self {
Patch::Set(v) => Some(v),
_ => None,
}
}
}
impl<'de, T> serde::Deserialize<'de> for Patch<T>
where
T: serde::Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<T>::deserialize(deserializer).map(|opt| match opt {
Some(v) => Patch::Set(v),
None => Patch::Clear,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[derive(Deserialize)]
struct Holder {
#[serde(default)]
desc: Patch<String>,
}
#[test]
fn missing_key_is_unchanged() {
let h: Holder = serde_json::from_value(json!({})).unwrap();
assert_eq!(h.desc, Patch::Unchanged);
}
#[test]
fn explicit_null_is_clear() {
let h: Holder = serde_json::from_value(json!({ "desc": null })).unwrap();
assert_eq!(h.desc, Patch::Clear);
}
#[test]
fn value_is_set() {
let h: Holder = serde_json::from_value(json!({ "desc": "x" })).unwrap();
assert_eq!(h.desc, Patch::Set("x".into()));
}
}
// `Patch<T>` lives in `super::patch` so other resources (collections,
// future PATCH endpoints) can reuse the same three-state semantics
// without re-importing through `manga::`.

View File

@@ -2,11 +2,15 @@ pub mod api_token;
pub mod author;
pub mod bookmark;
pub mod chapter;
pub mod collection;
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;
@@ -14,10 +18,14 @@ pub use api_token::ApiToken;
pub use author::{Author, AuthorRef, AuthorWithCount};
pub use bookmark::{Bookmark, BookmarkSummary};
pub use chapter::Chapter;
pub use collection::{Collection, CollectionSummary};
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;

View File

@@ -0,0 +1,81 @@
//! Three-state container for PATCH fields.
//!
//! `serde`'s default behaviour collapses both "field missing" and
//! "field is `null`" to `Option::None`, which means an `Option<T>`
//! patch field can't distinguish "leave alone" from "set to NULL".
//! `Patch<T>` carries that distinction by deserializing JSON `null`
//! into `Clear` and any value into `Set`; with `#[serde(default)]`
//! on the field, a missing key falls through to `Unchanged`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Patch<T> {
/// Field absent from the request — leave the column untouched.
#[default]
Unchanged,
/// Field present and explicitly `null` — set the column to NULL.
Clear,
/// Field present with a value — set the column to that value.
Set(T),
}
impl<T> Patch<T> {
/// Whether the request indicated this field should be written
/// (either to a new value or to NULL).
pub fn is_provided(&self) -> bool {
!matches!(self, Patch::Unchanged)
}
/// The value to bind when writing, or `None` for `Unchanged`/`Clear`.
pub fn set_value(&self) -> Option<&T> {
match self {
Patch::Set(v) => Some(v),
_ => None,
}
}
}
impl<'de, T> serde::Deserialize<'de> for Patch<T>
where
T: serde::Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<T>::deserialize(deserializer).map(|opt| match opt {
Some(v) => Patch::Set(v),
None => Patch::Clear,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct Holder {
#[serde(default)]
desc: Patch<String>,
}
#[test]
fn missing_key_is_unchanged() {
let h: Holder = serde_json::from_value(json!({})).unwrap();
assert_eq!(h.desc, Patch::Unchanged);
}
#[test]
fn explicit_null_is_clear() {
let h: Holder = serde_json::from_value(json!({ "desc": null })).unwrap();
assert_eq!(h.desc, Patch::Clear);
}
#[test]
fn value_is_set() {
let h: Holder = serde_json::from_value(json!({ "desc": "x" })).unwrap();
assert_eq!(h.desc, Patch::Set("x".into()));
}
}

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

@@ -46,7 +46,7 @@ pub async fn list_for_user(
user_id: Uuid,
limit: i64,
offset: i64,
) -> AppResult<Vec<BookmarkSummary>> {
) -> AppResult<(Vec<BookmarkSummary>, i64)> {
let rows = sqlx::query_as::<_, BookmarkSummary>(
r#"
SELECT
@@ -72,7 +72,12 @@ pub async fn list_for_user(
.bind(offset)
.fetch_all(pool)
.await?;
Ok(rows)
let (total,): (i64,) =
sqlx::query_as("SELECT count(*) FROM bookmarks WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult<Option<Uuid>> {

View File

@@ -52,22 +52,28 @@ pub async fn find_by_manga_and_number(
/// transaction with the per-page inserts. Returns `AppError::Conflict`
/// on the (manga_id, number) unique violation so handlers can surface a
/// 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>>(
executor: E,
manga_id: Uuid,
number: i32,
title: Option<&str>,
uploaded_by: Option<Uuid>,
) -> AppResult<Chapter> {
let result = sqlx::query_as::<_, Chapter>(
r#"
INSERT INTO chapters (manga_id, number, title)
VALUES ($1, $2, $3)
INSERT INTO chapters (manga_id, number, title, uploaded_by)
VALUES ($1, $2, $3, $4)
RETURNING id, manga_id, number, title, page_count, created_at
"#,
)
.bind(manga_id)
.bind(number)
.bind(title)
.bind(uploaded_by)
.fetch_one(executor)
.await;

View File

@@ -0,0 +1,280 @@
//! Collection persistence.
//!
//! Same plain-function pattern as `repo::bookmark`. Ownership is
//! tracked via `collections.user_id`; handlers call `find_owner`
//! before mutations to keep 403/404 honest.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::collection::{Collection, CollectionSummary};
use crate::domain::manga::Manga;
use crate::error::{AppError, AppResult};
pub async fn create(
pool: &PgPool,
user_id: Uuid,
name: &str,
description: Option<&str>,
) -> AppResult<Collection> {
let row = sqlx::query_as::<_, Collection>(
r#"
INSERT INTO collections (user_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, user_id, name, description, created_at, updated_at
"#,
)
.bind(user_id)
.bind(name.trim())
.bind(description)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::Conflict("a collection with this name already exists".into())
}
other => AppError::Database(other),
})?;
Ok(row)
}
pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<Collection> {
sqlx::query_as::<_, Collection>(
r#"
SELECT id, user_id, name, description, created_at, updated_at
FROM collections
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)
}
pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult<Option<Uuid>> {
let row: Option<(Uuid,)> =
sqlx::query_as("SELECT user_id FROM collections WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(u,)| u))
}
/// Paged list of one user's collections. Includes `manga_count` and up
/// to three sample cover image keys (newest-added first) so a card can
/// render without a follow-up fetch.
pub async fn list_for_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> AppResult<(Vec<CollectionSummary>, i64)> {
let rows = sqlx::query_as::<_, CollectionSummary>(
r#"
SELECT
c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
(SELECT count(*) FROM collection_mangas cm WHERE cm.collection_id = c.id)
AS manga_count,
COALESCE(
(
-- `array_agg(... ORDER BY ...)` is the only
-- spec-guaranteed way to preserve element order;
-- a subquery's ORDER BY isn't a contract the
-- outer aggregate has to honour. Adding manga_id
-- as a tiebreaker keeps the order stable when
-- multiple rows share `added_at` (bulk imports).
SELECT array_agg(cover_image_path ORDER BY added_at DESC, manga_id)
FROM (
SELECT m.cover_image_path, cm2.added_at, cm2.manga_id
FROM collection_mangas cm2
JOIN mangas m ON m.id = cm2.manga_id
WHERE cm2.collection_id = c.id
AND m.cover_image_path IS NOT NULL
ORDER BY cm2.added_at DESC, cm2.manga_id
LIMIT 3
) p
),
ARRAY[]::text[]
) AS sample_covers
FROM collections c
WHERE c.user_id = $1
ORDER BY c.updated_at DESC, c.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 collections WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
pub async fn update(
pool: &PgPool,
id: Uuid,
name: Option<&str>,
description_provided: bool,
description: Option<&str>,
) -> AppResult<Collection> {
let row = sqlx::query_as::<_, Collection>(
r#"
UPDATE collections
SET name = COALESCE($2, name),
description = CASE WHEN $3::boolean THEN $4 ELSE description END,
updated_at = now()
WHERE id = $1
RETURNING id, user_id, name, description, created_at, updated_at
"#,
)
.bind(id)
.bind(name.map(str::trim))
.bind(description_provided)
.bind(description)
.fetch_optional(pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::Conflict("a collection with this name already exists".into())
}
other => AppError::Database(other),
})?
.ok_or(AppError::NotFound)?;
Ok(row)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> {
sqlx::query("DELETE FROM collections WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Add a manga to a collection. Returns `true` if a new attachment was
/// created (handler picks 201), `false` if the manga was already in
/// the collection (handler picks 200). Touches `updated_at` so the
/// "recent collections" sort reflects activity.
///
/// FK violations (manga deleted between the handler's `exists` check
/// and this insert — a race the API can't fully close from the
/// outside) are remapped to `NotFound` so the handler returns 404
/// rather than 500.
pub async fn add_manga(
pool: &PgPool,
collection_id: Uuid,
manga_id: Uuid,
) -> AppResult<bool> {
let mut tx = pool.begin().await?;
let inserted = sqlx::query(
r#"
INSERT INTO collection_mangas (collection_id, manga_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
)
.bind(collection_id)
.bind(manga_id)
.execute(&mut *tx)
.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),
})?;
let rows_affected = inserted.rows_affected();
if rows_affected > 0 {
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
.bind(collection_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(rows_affected > 0)
}
pub async fn remove_manga(
pool: &PgPool,
collection_id: Uuid,
manga_id: Uuid,
) -> AppResult<()> {
let mut tx = pool.begin().await?;
let rows_affected = sqlx::query(
"DELETE FROM collection_mangas WHERE collection_id = $1 AND manga_id = $2",
)
.bind(collection_id)
.bind(manga_id)
.execute(&mut *tx)
.await?
.rows_affected();
if rows_affected > 0 {
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
.bind(collection_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn list_mangas(
pool: &PgPool,
collection_id: Uuid,
limit: i64,
offset: i64,
) -> AppResult<(Vec<Manga>, i64)> {
let rows = sqlx::query_as::<_, Manga>(
r#"
SELECT m.id, m.title, m.status, m.alt_titles, m.description,
m.cover_image_path, m.created_at, m.updated_at
FROM collection_mangas cm
JOIN mangas m ON m.id = cm.manga_id
WHERE cm.collection_id = $1
ORDER BY cm.added_at DESC, m.id
LIMIT $2 OFFSET $3
"#,
)
.bind(collection_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) =
sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1")
.bind(collection_id)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
/// Which of `user_id`'s collections currently contain `manga_id`?
/// Used by the "Add to collection" modal to pre-check the boxes.
pub async fn list_collections_containing(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
r#"
SELECT c.id
FROM collections c
JOIN collection_mangas cm ON cm.collection_id = c.id
WHERE c.user_id = $1
AND cm.manga_id = $2
ORDER BY c.updated_at DESC
"#,
)
.bind(user_id)
.bind(manga_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}

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
/// transaction. `status` is taken as a validated string — the handler
/// 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>>(
executor: E,
title: &str,
status: &str,
description: Option<&str>,
alt_titles: &[String],
uploaded_by: Option<Uuid>,
) -> AppResult<Manga> {
let row = sqlx::query_as::<_, Manga>(&format!(
r#"
INSERT INTO mangas (title, status, description, alt_titles)
VALUES ($1, $2, $3, $4)
INSERT INTO mangas (title, status, description, alt_titles, uploaded_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING {SELECT_COLS}
"#
))
@@ -199,6 +205,7 @@ pub async fn create<'e, E: PgExecutor<'e>>(
.bind(status)
.bind(description)
.bind(alt_titles)
.bind(uploaded_by)
.fetch_one(executor)
.await?;
Ok(row)

View File

@@ -2,10 +2,13 @@ pub mod api_token;
pub mod author;
pub mod bookmark;
pub mod chapter;
pub mod collection;
pub mod genre;
pub mod manga;
pub mod page;
pub mod read_progress;
pub mod session;
pub mod tag;
pub mod upload_history;
pub mod user;
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 manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// 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
.unwrap();
// Look up its id so we can bookmark it.
@@ -433,5 +433,8 @@ async fn list_me_returns_paged_envelope(pool: PgPool) {
assert!(body["items"].is_array());
assert_eq!(body["page"]["limit"], 50);
assert_eq!(body["page"]["offset"], 0);
assert!(body["page"]["total"].is_null());
// `total` is the unfiltered row count, returned so callers (e.g.
// the profile overview's bookmark counter) can show a number
// without paging through.
assert_eq!(body["page"]["total"], 0);
}

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>) {
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
.unwrap();
}

View File

@@ -0,0 +1,605 @@
mod common;
use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
async fn create_collection(
app: &axum::Router,
cookie: &str,
name: &str,
) -> Value {
let resp = app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/collections",
json!({ "name": name }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED, "create_collection failed");
common::body_json(resp).await
}
fn id_of(v: &Value) -> String {
v["id"].as_str().unwrap().to_string()
}
#[sqlx::test(migrations = "./migrations")]
async fn create_then_list_returns_only_own(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie_a) = common::register_user(&h.app).await;
let (_, cookie_b) = common::register_user(&h.app).await;
let _favs = create_collection(&h.app, &cookie_a, "Favorites").await;
let _read = create_collection(&h.app, &cookie_a, "Reading List").await;
// User B sees an empty list.
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_b))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["items"], json!([]));
assert_eq!(body["page"]["total"], 0);
// User A sees both.
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_a))
.await
.unwrap();
let body = common::body_json(resp).await;
let names: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
// Newest-updated first; both rows have the same updated_at on
// create so we just sanity-check membership.
assert_eq!(names.len(), 2);
assert!(names.contains(&"Favorites"));
assert!(names.contains(&"Reading List"));
// Empty collections render with manga_count 0 and an empty
// sample_covers array, not `null`.
for item in body["items"].as_array().unwrap() {
assert_eq!(item["manga_count"], 0);
assert_eq!(item["sample_covers"], json!([]));
}
}
#[sqlx::test(migrations = "./migrations")]
async fn duplicate_name_for_same_user_is_409(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let _ = create_collection(&h.app, &cookie, "Favorites").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
"/api/v1/collections",
json!({ "name": "favorites" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "conflict");
}
#[sqlx::test(migrations = "./migrations")]
async fn two_users_can_share_a_collection_name(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let _ = create_collection(&h.app, &a, "Favorites").await;
// No conflict — uniqueness is per-(user_id, lower(name)).
let _ = create_collection(&h.app, &b, "Favorites").await;
}
#[sqlx::test(migrations = "./migrations")]
async fn create_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.oneshot(common::post_json(
"/api/v1/collections",
json!({ "name": "Anon" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_blank_name_with_422(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
"/api/v1/collections",
json!({ "name": " " }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_one_returns_404_for_non_owner_no_existence_leak(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &a, "Favorites").await;
let id = id_of(&coll);
// Owner-mismatch is collapsed to 404 so the API doesn't disclose
// collection existence to non-owners. Otherwise an attacker could
// distinguish "exists, not yours" from "doesn't exist" by status.
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/collections/{id}"),
&b,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn add_manga_is_idempotent_and_picks_201_then_200(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 coll = create_collection(&h.app, &cookie, "Favorites").await;
let coll_id = id_of(&coll);
let req = || {
common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&cookie,
)
};
let first = h.app.clone().oneshot(req()).await.unwrap();
assert_eq!(first.status(), StatusCode::CREATED);
let second = h.app.oneshot(req()).await.unwrap();
assert_eq!(second.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn add_manga_returns_404_when_manga_missing(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "Favorites").await;
let coll_id = id_of(&coll);
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": Uuid::new_v4().to_string() }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn add_manga_to_someone_elses_collection_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let coll_a = create_collection(&h.app, &a, "Mine").await;
let coll_a_id = id_of(&coll_a);
let manga_id = common::seed_manga_via_api(&h.app, &b, "Anything").await;
let resp = h
.app
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_a_id}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&b,
))
.await
.unwrap();
// 404 not 403 — same non-existence-leak rationale as `get_one`.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_on_other_users_collection_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, a) = common::register_user(&h.app).await;
let (_, b) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &a, "Mine").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "name": "Hijacked" }),
&b,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_description_null_clears_existing_value(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "C").await;
let id = id_of(&coll);
// Seed a description first via PATCH.
let _ = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": "starting desc" }),
&cookie,
))
.await
.unwrap();
// Now PATCH with description=null and expect the column cleared.
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": null }),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
assert!(body["description"].is_null(), "expected description cleared");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_description_empty_string_sets_empty_not_null(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "C").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": "" }),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
// Empty string is a valid distinct value; only `null` clears.
assert_eq!(body["description"], "");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_description_omitted_leaves_value_intact(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "C").await;
let id = id_of(&coll);
let _ = h
.app
.clone()
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "description": "Keep me" }),
&cookie,
))
.await
.unwrap();
// PATCH that doesn't mention description must not touch it.
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "name": "Renamed" }),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(body["name"], "Renamed");
assert_eq!(body["description"], "Keep me");
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_with_empty_body_leaves_row_unchanged(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "Stable").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["name"], "Stable");
}
#[sqlx::test(migrations = "./migrations")]
async fn my_collections_for_unknown_manga_returns_empty_list(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/mangas/{}/my-collections", Uuid::new_v4()),
&cookie,
))
.await
.unwrap();
// Non-existent manga is treated the same as a manga the user
// hasn't collected — empty list. The handler comment documents
// this; the test pins it.
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["collection_ids"], json!([]));
}
#[sqlx::test(migrations = "./migrations")]
async fn list_mangas_returns_collection_contents(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 _untagged = common::seed_manga_via_api(&h.app, &cookie, "NotInIt").await;
let coll = create_collection(&h.app, &cookie, "Mix").await;
let coll_id = id_of(&coll);
for m in [m1, m2] {
let r = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": m.to_string() }),
&cookie,
))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
}
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
let titles: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|m| m["title"].as_str().unwrap())
.collect();
// Newest-added first.
assert_eq!(titles, vec!["Second", "First"]);
assert_eq!(body["page"]["total"], 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn remove_manga_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, "M").await;
let coll = create_collection(&h.app, &cookie, "C").await;
let coll_id = id_of(&coll);
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let first = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::NO_CONTENT);
// Removing again is still a 204 — DELETE is idempotent.
let second = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "./migrations")]
async fn my_collections_for_manga_lists_only_owned_containing(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, "X").await;
let a_coll = create_collection(&h.app, &a, "A's").await;
let b_coll = create_collection(&h.app, &b, "B's").await;
let a_coll_id = id_of(&a_coll);
let b_coll_id = id_of(&b_coll);
for (coll, cookie) in [(&a_coll_id, &a), (&b_coll_id, &b)] {
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
cookie,
))
.await
.unwrap();
}
let resp = h
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/mangas/{manga_id}/my-collections"),
&a,
))
.await
.unwrap();
let body = common::body_json(resp).await;
let ids: Vec<&str> = body["collection_ids"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(ids, vec![a_coll_id.as_str()]);
}
#[sqlx::test(migrations = "./migrations")]
async fn patch_collection_updates_name_and_description(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let coll = create_collection(&h.app, &cookie, "Old name").await;
let id = id_of(&coll);
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/collections/{id}"),
json!({ "name": "New name", "description": "Some notes" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
assert_eq!(body["name"], "New name");
assert_eq!(body["description"], "Some notes");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_collection_cascades_attachments(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, "M").await;
let coll = create_collection(&h.app, &cookie, "C").await;
let coll_id = id_of(&coll);
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/collections/{coll_id}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let (count,): (i64,) =
sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1")
.bind(Uuid::parse_str(&coll_id).unwrap())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0, "collection_mangas should cascade-delete with the collection");
}
#[sqlx::test(migrations = "./migrations")]
async fn list_summary_carries_sample_covers_when_mangas_attached(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Seed a manga with a cover via the upload endpoint so the
// cover_image_path column gets populated.
let make_metadata = |title: &str| {
common::MultipartBuilder::new()
.add_json("metadata", json!({ "title": title }))
.add_file("cover", "cover.png", "image/png", &common::fake_png_bytes())
};
let resp = h
.app
.clone()
.oneshot(common::post_multipart_with_cookie(
"/api/v1/mangas",
make_metadata("With cover"),
&cookie,
))
.await
.unwrap();
let body = common::body_json(resp).await;
let manga_id = body["id"].as_str().unwrap().to_string();
let coll = create_collection(&h.app, &cookie, "Visual").await;
let coll_id = id_of(&coll);
let r = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/collections/{coll_id}/mangas"),
json!({ "manga_id": manga_id }),
&cookie,
))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::CREATED);
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let item = &body["items"][0];
assert_eq!(item["manga_count"], 1);
let covers = item["sample_covers"].as_array().unwrap();
assert_eq!(covers.len(), 1);
assert!(covers[0]
.as_str()
.unwrap()
.starts_with(&format!("mangas/{manga_id}/cover")));
}

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()
}
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> {
Request::builder()
.method("DELETE")

View File

@@ -14,9 +14,25 @@ async function stubAuthenticated(page: Page) {
body: JSON.stringify({ user: userFixture })
})
);
// Profile overview hits these for the count cards — return zeros
// unless a test overrides.
await page.route('**/api/v1/me/bookmarks?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 1, offset: 0, total: 0 } })
})
);
await page.route('**/api/v1/me/collections?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 1, offset: 0, total: 0 } })
})
);
}
test('settings link shows for authed users and reaches the password form', async ({ page }) => {
test('Profile link in nav for authed users; landing shows counts', async ({ page }) => {
await stubAuthenticated(page);
await page.route('**/api/v1/mangas?*', (route) =>
route.fulfill({
@@ -27,9 +43,18 @@ test('settings link shows for authed users and reaches the password form', async
);
await page.goto('/');
await expect(page.getByTestId('nav-settings')).toBeVisible();
await page.getByTestId('nav-settings').click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId('nav-profile')).toBeVisible();
await page.getByTestId('nav-profile').click();
await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByTestId('overview-bookmarks')).toBeVisible();
await expect(page.getByTestId('overview-collections')).toBeVisible();
});
test('Account tab reaches the password form', async ({ page }) => {
await stubAuthenticated(page);
await page.goto('/profile');
await page.getByTestId('tab-account').click();
await expect(page).toHaveURL(/\/profile\/account$/);
await expect(page.getByTestId('password-form')).toBeVisible();
});
@@ -43,7 +68,7 @@ test('changing password shows success and clears the form', async ({ page }) =>
await route.fulfill({ status: 204 });
});
await page.goto('/settings');
await page.goto('/profile/account');
await page.getByTestId('current-password').fill('hunter2hunter2');
await page.getByTestId('new-password').fill('freshpassfreshpass');
await page.getByTestId('confirm-password').fill('freshpassfreshpass');
@@ -55,7 +80,6 @@ test('changing password shows success and clears the form', async ({ page }) =>
current_password: 'hunter2hunter2',
new_password: 'freshpassfreshpass'
});
// Form should clear after success.
await expect(page.getByTestId('current-password')).toHaveValue('');
});
@@ -71,7 +95,7 @@ test('wrong current password surfaces the 401 envelope inline', async ({ page })
})
);
await page.goto('/settings');
await page.goto('/profile/account');
await page.getByTestId('current-password').fill('definitelyNotIt');
await page.getByTestId('new-password').fill('freshpassfreshpass');
await page.getByTestId('confirm-password').fill('freshpassfreshpass');
@@ -83,7 +107,7 @@ test('wrong current password surfaces the 401 envelope inline', async ({ page })
test('mismatched new + confirm disables the submit button', async ({ page }) => {
await stubAuthenticated(page);
await page.goto('/settings');
await page.goto('/profile/account');
await page.getByTestId('current-password').fill('hunter2hunter2');
await page.getByTestId('new-password').fill('freshpassfreshpass');
await page.getByTestId('confirm-password').fill('different');
@@ -92,7 +116,7 @@ test('mismatched new + confirm disables the submit button', async ({ page }) =>
await expect(page.getByTestId('password-submit')).toBeDisabled();
});
test('anonymous user sees a sign-in prompt on /settings', async ({ page }) => {
test('anonymous user sees a profile sign-in prompt', async ({ page }) => {
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
@@ -103,7 +127,16 @@ test('anonymous user sees a sign-in prompt on /settings', async ({ page }) => {
})
);
await page.goto('/settings');
await expect(page.getByTestId('settings-signin')).toBeVisible();
await page.goto('/profile');
await expect(page.getByTestId('profile-signin')).toBeVisible();
await expect(page.getByTestId('password-form')).toHaveCount(0);
});
test('/settings 308-redirects to /profile/preferences', async ({ page }) => {
await stubAuthenticated(page);
await page.goto('/settings');
await expect(page).toHaveURL(/\/profile\/preferences$/);
// The theme radio is visually hidden (decorated label wraps it), so
// assert presence rather than CSS visibility.
await expect(page.getByTestId('theme-radio-system')).toBeAttached();
});

View File

@@ -201,13 +201,13 @@ test('reader-mode preference set on one page is honored when the reader opens',
);
});
test('settings page hides the gap picker while in single-page mode', async ({ page }) => {
test('preferences page hides the gap picker while in single-page mode', async ({ page }) => {
// Visually verifies the conditional render. The radio-click semantics
// are exercised in src/lib/preferences.svelte.test.ts; the visible
// mode toggle in the reader top bar covers the cross-route propagation
// path in the test above.
await mockReaderApis(page);
await page.goto('/settings');
await page.goto('/profile/preferences');
await expect(page.getByTestId('reader-mode-radio-single')).toBeAttached();
await expect(page.getByTestId('reader-mode-radio-continuous')).toBeAttached();

View File

@@ -8,14 +8,18 @@ const userFixture = {
const mangaFixture = {
id: 'm1',
title: 'Berserk',
author: 'Kentaro Miura',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
updated_at: '2026-01-01T00:00:00Z',
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
genres: [],
tags: []
};
async function mockBaseUploadApis(page: Page) {
async function stubAuthenticatedAndGenres(page: Page) {
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 200,
@@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) {
body: JSON.stringify({ user: userFixture })
})
);
await page.route('**/api/v1/mangas?*', (route) =>
await page.route('**/api/v1/genres', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [mangaFixture],
page: { limit: 200, offset: 0, total: 1 }
})
body: JSON.stringify([
{ id: 'g-action', name: 'Action' },
{ id: 'g-fantasy', name: 'Fantasy' }
])
})
);
}
@@ -45,61 +49,20 @@ test('anonymous user sees sign-in prompt on /upload', async ({ page }) => {
})
})
);
await page.route('**/api/v1/mangas?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } })
})
await page.route('**/api/v1/genres', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
await page.goto('/upload');
await expect(page.getByTestId('upload-signin')).toBeVisible();
});
test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => {
await mockBaseUploadApis(page);
// Backend rejects with 415 unsupported_media_type — we want to see
// the human message rendered as the chapter error.
await page.route('**/api/v1/mangas/m1/chapters', (route) =>
route.fulfill({
status: 415,
contentType: 'application/json',
body: JSON.stringify({
error: {
code: 'unsupported_media_type',
message: 'page[0]: unsupported image type application/pdf'
}
})
})
);
await page.goto('/upload');
await page.getByTestId('chapter-manga').selectOption('m1');
await page.getByTestId('chapter-number').fill('1');
// Client validator allows image/png; we lie about the file type so
// the request actually reaches the (mocked) backend, exercising the
// 415 envelope path.
await page.getByTestId('chapter-pages-input').setInputFiles({
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('%PDF-1.4', 'utf-8')
});
await page.getByTestId('chapter-submit').click();
await expect(page.getByTestId('chapter-error')).toContainText(
'unsupported image type'
);
});
test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
await mockBaseUploadApis(page);
test('/upload creates a manga with no staged chapters and lands on the manga page', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
let createdManga: typeof mangaFixture | null = null;
let createdChapter: { id: string; number: number } | null = null;
await page.route('**/api/v1/mangas', (route) => {
if (route.request().method() === 'POST') {
createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' };
@@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
route.fallback();
}
});
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
await page.route('**/api/v1/mangas/m2', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...mangaFixture, id: 'm2', title: 'Naruto' })
})
);
await page.route('**/api/v1/mangas/m2/chapters*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m2', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/upload');
await page.getByTestId('manga-title').fill('Naruto');
await page.getByTestId('manga-submit').click();
// After create, success → navigate to /manga/{id}.
await expect(page).toHaveURL(/\/manga\/m2$/);
expect(createdManga).not.toBeNull();
});
test('/upload stages a chapter with renamed page files (page-NNN.<ext>)', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
let createdManga: typeof mangaFixture | null = null;
let submittedPageNames: string[] = [];
await page.route('**/api/v1/mangas', (route) => {
if (route.request().method() === 'POST') {
createdChapter = { id: 'c1', number: 1 };
createdManga = { ...mangaFixture, id: 'm3', title: 'Vinland Saga' };
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(createdManga)
});
} else {
route.fallback();
}
});
await page.route('**/api/v1/mangas/m3/chapters', (route) => {
if (route.request().method() === 'POST') {
const post = route.request().postDataBuffer()?.toString('binary') ?? '';
// Pull every Content-Disposition filename out of the
// multipart body — that's what the server (and proxies,
// logs) would see. We expect only renamed `page-NNN.*`
// entries, never the original filenames.
const matches = [
...post.matchAll(/filename="([^"]+)"/g)
].map((m) => m[1]);
submittedPageNames = matches.filter((n) => n.startsWith('page-'));
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'c1',
manga_id: 'm1',
manga_id: 'm3',
number: 1,
title: null,
page_count: 2,
@@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
route.fallback();
}
});
await page.route('**/api/v1/mangas/m3', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...mangaFixture, id: 'm3', title: 'Vinland Saga' })
})
);
await page.route('**/api/v1/mangas/m3/chapters?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m3', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/upload');
// Create manga.
await page.getByTestId('manga-title').fill('Naruto');
await page.getByTestId('manga-submit').click();
await expect(page.getByTestId('manga-success')).toContainText('Created');
expect(createdManga).not.toBeNull();
// Upload chapter with two pages.
await page.getByTestId('chapter-manga').selectOption('m1');
await page.getByTestId('chapter-number').fill('1');
await page.getByTestId('chapter-pages-input').setInputFiles([
{
name: '1.png',
mimeType: 'image/png',
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
},
{
name: '2.png',
mimeType: 'image/png',
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
}
await page.getByTestId('manga-title').fill('Vinland Saga');
await page.getByTestId('add-chapter').click();
const pngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
await expect(page.getByTestId('chapter-pages-list')).toContainText('1.png');
await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png');
await page
.getByTestId('staged-chapter-pages-input')
.setInputFiles([
{ name: 'IMG_2837.png', mimeType: 'image/png', buffer: pngBytes },
{ name: 'random_file.png', mimeType: 'image/png', buffer: pngBytes }
]);
// The list renders "Page 001" / "Page 002" not the original filenames.
const list = page.getByTestId('staged-chapter-pages-list');
await expect(list).toContainText('Page 001');
await expect(list).toContainText('Page 002');
// Original filenames are visible as a dimmed caption (uploader-
// reference; dropped after the row).
await expect(list).toContainText('IMG_2837.png');
await page.getByTestId('chapter-submit').click();
await expect(page.getByTestId('chapter-success')).toContainText(
'2 pages'
);
expect(createdChapter).not.toBeNull();
await page.getByTestId('manga-submit').click();
await expect(page).toHaveURL(/\/manga\/m3$/);
expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']);
});
test('client preflight blocks oversized files without hitting the network', async ({ page }) => {
await mockBaseUploadApis(page);
test('/manga/[id]/upload-chapter happy path uploads renamed pages', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
await page.route('**/api/v1/mangas/m1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route('**/api/v1/mangas/m1/chapters?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [{ id: 'c0', manga_id: 'm1', number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }],
page: { limit: 200, offset: 0, total: 1 }
})
})
);
let submitted: string[] = [];
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
if (route.request().method() === 'POST') {
const post = route.request().postDataBuffer()?.toString('binary') ?? '';
submitted = [...post.matchAll(/filename="([^"]+)"/g)].map((m) => m[1]);
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'c-new',
manga_id: 'm1',
number: 2,
title: null,
page_count: 1,
created_at: '2026-01-01T00:00:00Z'
})
});
} else {
route.fallback();
}
});
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m1', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/manga/m1/upload-chapter');
// Default chapter number is the next free one (existing max 1 → 2).
await expect(page.getByTestId('chapter-number')).toHaveValue('2');
const pngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
await page.getByTestId('pages-input').setInputFiles({
name: 'whatever.png',
mimeType: 'image/png',
buffer: pngBytes
});
await expect(page.getByTestId('pages-list')).toContainText('Page 001');
await page.getByTestId('chapter-submit').click();
await expect(page).toHaveURL(/\/manga\/m1$/);
expect(submitted.filter((n) => n.startsWith('page-'))).toEqual([
'page-001.png'
]);
});
test('chapter upload client preflight blocks oversized files', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
await page.route('**/api/v1/mangas/m1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route('**/api/v1/mangas/m1/chapters?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 200, offset: 0, total: 0 }
})
})
);
let chapterPostCalls = 0;
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
if (route.request().method() === 'POST') chapterPostCalls += 1;
route.fallback();
});
await page.goto('/upload');
await page.getByTestId('chapter-manga').selectOption('m1');
await page.getByTestId('chapter-number').fill('1');
// A ~21 MiB buffer — exceeds the 20 MiB client cap.
await page.goto('/manga/m1/upload-chapter');
const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
await page.getByTestId('chapter-pages-input').setInputFiles({
await page.getByTestId('pages-input').setInputFiles({
name: 'huge.png',
mimeType: 'image/png',
buffer: big
});
await expect(page.getByTestId('chapter-pages-list')).toContainText('too large');
await expect(page.getByTestId('pages-list')).toContainText('too large');
await expect(page.getByTestId('chapter-submit')).toBeDisabled();
expect(chapterPostCalls).toBe(0);
});

View File

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

View File

@@ -7,7 +7,12 @@ import {
afterEach,
type MockInstance
} from 'vitest';
import { listChapters, getChapter, getChapterPages } from './chapters';
import {
listChapters,
getChapter,
getChapterPages,
createChapter
} from './chapters';
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), {
@@ -87,6 +92,43 @@ describe('chapters api client', () => {
});
});
it('createChapter POSTs multipart and renames page files to page-NNN.<ext>', async () => {
fetchSpy.mockResolvedValueOnce(ok({ ...chapterFixture, page_count: 3 }));
const pages = [
new File([new Uint8Array([1, 2])], 'IMG_2837.HEIC', { type: 'image/jpeg' }),
new File([new Uint8Array([3, 4])], 'random.png', { type: 'image/png' }),
// No extension; MIME-derived fallback should kick in.
new File([new Uint8Array([5])], 'scan_42', { type: 'image/webp' })
];
const result = await createChapter(
'm1',
{ number: 1, title: null },
pages
);
expect(result.page_count).toBe(3);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
const form = init.body as FormData;
// Metadata part is JSON.
const metadata = form.get('metadata') as Blob;
expect(metadata.type).toBe('application/json');
// Three pages, all renamed; original filenames discarded.
const submitted = form.getAll('page') as File[];
expect(submitted).toHaveLength(3);
// Original-extension preferred over MIME-derived; capitalised
// .HEIC dropped because it's not in the allowed list, so the
// MIME-derived `.jpg` wins.
expect(submitted[0].name).toBe('page-001.jpg');
expect(submitted[1].name).toBe('page-002.png');
expect(submitted[2].name).toBe('page-003.webp');
// No original filenames leak through.
for (const f of submitted) {
expect(f.name).not.toMatch(/IMG_2837|random|scan_42/);
}
});
it('getChapterPages unwraps the {pages} envelope into the array', async () => {
fetchSpy.mockResolvedValueOnce(
ok({

View File

@@ -55,3 +55,66 @@ export async function getChapterPages(
);
return r.pages;
}
export type NewChapter = {
number: number;
title?: string | null;
};
/**
* `POST /api/v1/mangas/:id/chapters` is multipart: a `metadata` part
* (JSON) plus one or more ordered `page` parts. Each page file is
* renamed to `page-NNN.<ext>` before submission so the user's
* original filenames (often personally-identifying or just messy:
* `IMG_2837.HEIC`, `~/scans/full chapter pack/`) don't end up in
* request bodies or server logs. The bytes are unchanged — the
* backend still sniffs the MIME from magic bytes and stores under
* its own `{nnnn}.{ext}` scheme.
*/
export async function createChapter(
mangaId: string,
metadata: NewChapter,
pages: File[]
): Promise<Chapter> {
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
pages.forEach((file, i) => {
const ext = extensionFor(file);
const renamed = new File(
[file],
`page-${String(i + 1).padStart(3, '0')}${ext}`,
{ type: file.type }
);
form.append('page', renamed);
});
return request<Chapter>(
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters`,
{ method: 'POST', body: form }
);
}
/**
* Pick a sensible extension for the renamed multipart part. Prefer
* the original filename's extension when present (jpg/jpeg/png/webp/
* gif/avif), otherwise derive from the MIME type. Falls back to an
* empty string so the renamed file is just `page-001` — the
* server sniffs bytes anyway.
*/
function extensionFor(file: File): string {
const dot = file.name.lastIndexOf('.');
if (dot > 0) {
const ext = file.name.slice(dot).toLowerCase();
if (/^\.(jpe?g|png|webp|gif|avif)$/.test(ext)) return ext;
}
const fromMime: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/avif': '.avif'
};
return fromMime[file.type] ?? '';
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { ApiError } from './client';
import { ApiError, request } from './client';
import { getManga } from './mangas';
describe('request error envelope parsing', () => {
@@ -48,6 +48,20 @@ describe('request error envelope parsing', () => {
expect(err.code).toBe('http_error');
});
it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => {
// Regression: addMangaToCollection is typed `void` and the
// backend returns 201 (created) / 200 (already there) with
// no body. Without the empty-body short-circuit, `res.json()`
// would throw `JSON.parse: unexpected end of data`.
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 201 }));
const created = await request<void>('/v1/whatever', { method: 'POST' });
expect(created).toBeUndefined();
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 }));
const ok200 = await request<void>('/v1/whatever', { method: 'POST' });
expect(ok200).toBeUndefined();
});
it('falls back to http_error code when JSON has no error envelope', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'oops' }), {

View File

@@ -56,10 +56,18 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
throw new ApiError(res.status, code, message);
}
// Any empty body (not just 204) returns undefined — the manga-add
// endpoint, for instance, signals create-vs-already-present via
// 201/200 with no body, and callers typed `request<void>` would
// otherwise blow up on `res.json()` parsing an empty string.
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
const text = await res.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export type Manga = {

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import {
listMyCollections,
listMyCollectionsOrEmpty,
createCollection,
getCollection,
updateCollection,
deleteCollection,
listCollectionMangas,
addMangaToCollection,
removeMangaFromCollection,
getMyCollectionsContaining
} from './collections';
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' }
});
}
function collectionFixture(extra: Record<string, unknown> = {}) {
return {
id: 'c1',
user_id: 'u1',
name: 'Favorites',
description: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
manga_count: 0,
sample_covers: [],
...extra
};
}
describe('collections api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('listMyCollections returns the paged envelope', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [collectionFixture()],
page: { limit: 50, offset: 0, total: 1 }
})
);
const result = await listMyCollections();
expect(result.items[0].name).toBe('Favorites');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/me\/collections$/);
});
it('listMyCollectionsOrEmpty returns empty page on 401', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
const result = await listMyCollectionsOrEmpty();
expect(result.items).toEqual([]);
expect(result.page.total).toBeNull();
});
it('listMyCollectionsOrEmpty re-throws non-401 errors', async () => {
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops'));
await expect(listMyCollectionsOrEmpty()).rejects.toMatchObject({ status: 500 });
});
it('createCollection POSTs JSON to /v1/collections', async () => {
fetchSpy.mockResolvedValueOnce(ok(collectionFixture(), 201));
const c = await createCollection({ name: 'Favorites' });
expect(c.name).toBe('Favorites');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({ name: 'Favorites' });
});
it('getCollection encodes the id', async () => {
fetchSpy.mockResolvedValueOnce(ok(collectionFixture()));
await getCollection('id with space');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain('/v1/collections/id%20with%20space');
});
it('updateCollection PATCHes with the patch body', async () => {
fetchSpy.mockResolvedValueOnce(ok(collectionFixture({ name: 'Read later' })));
const updated = await updateCollection('c1', { name: 'Read later' });
expect(updated.name).toBe('Read later');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({ name: 'Read later' });
});
it('deleteCollection issues DELETE', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await deleteCollection('c1');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('DELETE');
});
it('listCollectionMangas returns the paged envelope of mangas', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [
{
id: 'm1',
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'
}
],
page: { limit: 50, offset: 0, total: 1 }
})
);
const r = await listCollectionMangas('c1');
expect(r.items[0].title).toBe('Berserk');
});
it('addMangaToCollection POSTs the manga_id', async () => {
fetchSpy.mockResolvedValueOnce(ok({}, 201));
await addMangaToCollection('c1', 'm9');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm9' });
});
it('removeMangaFromCollection DELETEs the nested resource', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await removeMangaFromCollection('c1', 'm9');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/collections\/c1\/mangas\/m9$/);
});
it('getMyCollectionsContaining returns the id list', async () => {
fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c3'] }));
const ids = await getMyCollectionsContaining('m1');
expect(ids).toEqual(['c1', 'c3']);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/mangas\/m1\/my-collections$/);
});
});

View File

@@ -0,0 +1,139 @@
import { ApiError, request, type Manga, type Page } from './client';
export type Collection = {
id: string;
user_id: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
};
/** Returned by `GET /v1/me/collections` — enriched for card rendering. */
export type CollectionSummary = Collection & {
manga_count: number;
/** Up to 3 cover image keys, newest-added first. */
sample_covers: string[];
};
export type CollectionsPage = {
items: CollectionSummary[];
page: Page;
};
export type CollectionMangasPage = {
items: Manga[];
page: Page;
};
export type NewCollection = {
name: string;
description?: string | null;
};
export type CollectionPatch = {
name?: string;
description?: string | null;
};
export type ListMyOptions = { limit?: number; offset?: number };
export async function listMyCollections(
opts: ListMyOptions = {}
): Promise<CollectionsPage> {
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<CollectionsPage>(`/v1/me/collections${qs ? `?${qs}` : ''}`);
}
/** Empty page on 401 so guest-rendering pages don't have to special-case. */
export async function listMyCollectionsOrEmpty(): Promise<CollectionsPage> {
try {
return await listMyCollections();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { items: [], page: { limit: 50, offset: 0, total: null } };
}
throw e;
}
}
export async function createCollection(
input: NewCollection
): Promise<Collection> {
return request<Collection>('/v1/collections', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input)
});
}
export async function getCollection(id: string): Promise<Collection> {
return request<Collection>(`/v1/collections/${encodeURIComponent(id)}`);
}
export async function updateCollection(
id: string,
patch: CollectionPatch
): Promise<Collection> {
return request<Collection>(`/v1/collections/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch)
});
}
export async function deleteCollection(id: string): Promise<void> {
await request<void>(`/v1/collections/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
}
export async function listCollectionMangas(
id: string,
opts: ListMyOptions = {}
): Promise<CollectionMangasPage> {
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<CollectionMangasPage>(
`/v1/collections/${encodeURIComponent(id)}/mangas${qs ? `?${qs}` : ''}`
);
}
export async function addMangaToCollection(
collectionId: string,
mangaId: string
): Promise<void> {
await request<void>(
`/v1/collections/${encodeURIComponent(collectionId)}/mangas`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ manga_id: mangaId })
}
);
}
export async function removeMangaFromCollection(
collectionId: string,
mangaId: string
): Promise<void> {
await request<void>(
`/v1/collections/${encodeURIComponent(collectionId)}/mangas/${encodeURIComponent(mangaId)}`,
{ method: 'DELETE' }
);
}
/** Which of the user's collections currently contain this manga. */
export async function getMyCollectionsContaining(
mangaId: string
): Promise<string[]> {
const r = await request<{ collection_ids: string[] }>(
`/v1/mangas/${encodeURIComponent(mangaId)}/my-collections`
);
return r.collection_ids;
}

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

@@ -0,0 +1,279 @@
<script lang="ts">
import Modal from './Modal.svelte';
import {
addMangaToCollection,
createCollection,
listMyCollections,
getMyCollectionsContaining,
removeMangaFromCollection,
type CollectionSummary
} from '$lib/api/collections';
import Plus from '@lucide/svelte/icons/plus';
let {
open,
mangaId,
onClose
}: {
open: boolean;
mangaId: string;
onClose: () => void;
} = $props();
let collections = $state<CollectionSummary[]>([]);
let containingIds = $state<Set<string>>(new Set());
let busyIds = $state<Set<string>>(new Set());
let newName = $state('');
let creating = $state(false);
let loading = $state(false);
let error: string | null = $state(null);
// Refetch every time the modal opens (and when the manga id changes
// mid-session — unlikely but cheap). The data is per-user and per-
// manga, so re-fetching is the simplest way to stay in sync with
// changes made elsewhere (e.g., a collection deleted on another page).
$effect(() => {
if (open) {
void load();
}
});
async function load() {
loading = true;
error = null;
try {
const [page, ids] = await Promise.all([
listMyCollections({ limit: 200 }),
getMyCollectionsContaining(mangaId)
]);
collections = page.items;
containingIds = new Set(ids);
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
// Functional set updates that read the latest state at mutation
// time, so concurrent toggles on different rows don't clobber
// each other by building from a stale snapshot.
function withAdd<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s);
n.add(v);
return n;
}
function withDelete<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s);
n.delete(v);
return n;
}
async function toggle(collection: CollectionSummary) {
if (busyIds.has(collection.id)) return;
const wasIn = containingIds.has(collection.id);
// Optimistic toggle — local set first; revert on failure.
containingIds = wasIn
? withDelete(containingIds, collection.id)
: withAdd(containingIds, collection.id);
busyIds = withAdd(busyIds, collection.id);
try {
if (wasIn) {
await removeMangaFromCollection(collection.id, mangaId);
collection.manga_count = Math.max(0, collection.manga_count - 1);
} else {
await addMangaToCollection(collection.id, mangaId);
collection.manga_count += 1;
}
} catch (e) {
// Revert (read latest containingIds, not the pre-toggle snapshot).
containingIds = wasIn
? withAdd(containingIds, collection.id)
: withDelete(containingIds, collection.id);
error = (e as Error).message;
} finally {
busyIds = withDelete(busyIds, collection.id);
}
}
async function createAndAdd() {
const name = newName.trim();
if (!name || creating) return;
creating = true;
error = null;
try {
const created = await createCollection({ name });
// The list endpoint sorts by updated_at DESC; adding the
// manga immediately also bumps it. Append a synthetic
// summary so the new collection appears checked-on right
// away rather than waiting for a refetch.
await addMangaToCollection(created.id, mangaId);
collections = [
{
...created,
manga_count: 1,
sample_covers: []
},
...collections
];
containingIds = new Set([...containingIds, created.id]);
newName = '';
} catch (e) {
error = (e as Error).message;
} finally {
creating = false;
}
}
function onCreateSubmit(e: SubmitEvent) {
e.preventDefault();
void createAndAdd();
}
</script>
<Modal {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
{#if loading}
<p class="status">Loading your collections…</p>
{:else if error}
<p class="error" role="alert" data-testid="add-to-collection-error">{error}</p>
{:else if collections.length === 0}
<p class="status" data-testid="no-collections">
You don't have any collections yet. Create one below to get started.
</p>
{:else}
<ul class="collection-list">
{#each collections as c (c.id)}
{@const checked = containingIds.has(c.id)}
{@const busy = busyIds.has(c.id)}
<li>
<label class="row" class:checked>
<input
type="checkbox"
{checked}
disabled={busy}
onchange={() => toggle(c)}
data-testid={`collection-toggle-${c.id}`}
/>
<span class="row-label">
<span class="row-name">{c.name}</span>
<span class="row-count">
{c.manga_count}
{c.manga_count === 1 ? 'manga' : 'mangas'}
</span>
</span>
</label>
</li>
{/each}
</ul>
{/if}
<form
class="create-form"
onsubmit={onCreateSubmit}
action="javascript:void(0)"
>
<input
type="text"
bind:value={newName}
maxlength="64"
placeholder="Create new collection"
aria-label="New collection name"
data-testid="new-collection-name"
/>
<button
type="submit"
class="create-btn"
disabled={!newName.trim() || creating}
data-testid="create-collection-btn"
>
<Plus size={14} aria-hidden="true" />
<span>{creating ? 'Creating…' : 'Create + add'}</span>
</button>
</form>
</Modal>
<style>
.status {
color: var(--text-muted);
}
.error {
color: var(--danger);
margin: 0 0 var(--space-2);
}
.collection-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 16rem;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition);
}
.row:hover {
background: var(--surface-elevated);
}
.row.checked {
background: var(--primary-soft-bg);
}
.row-label {
display: flex;
flex-direction: column;
min-width: 0;
}
.row-name {
color: var(--text);
font-weight: var(--weight-medium);
}
.row-count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.create-form {
display: flex;
gap: var(--space-2);
align-items: center;
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.create-form input {
flex: 1;
min-width: 0;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 36px;
white-space: nowrap;
}
.create-btn:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
</style>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import type { Bookmark } from '$lib/api/bookmarks';
import BookImage from '@lucide/svelte/icons/book-image';
let {
bookmarks,
testid
}: {
bookmarks: Bookmark[];
testid?: string;
} = $props();
</script>
<ul class="bookmark-list" data-testid={testid ?? 'bookmark-list'}>
{#each bookmarks as b (b.id)}
<li class="bookmark">
<a href="/manga/{b.manga_id}" class="cover-link" aria-hidden="true" tabindex="-1">
{#if b.manga_cover_image_path}
<img
src={fileUrl(b.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={22} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a
href="/manga/{b.manga_id}"
class="title"
data-testid="bookmark-title"
>
{b.manga_title ?? 'Unknown manga'}
</a>
{#if b.chapter_id && b.chapter_number != null}
<a
href="/manga/{b.manga_id}/chapter/{b.chapter_number}"
class="target"
>
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}
</a>
{:else if b.chapter_id}
<!-- Chapter bookmark whose chapter was deleted;
chapter_id != null but chapter_number == null
because the LEFT JOIN found nothing. -->
<span class="target muted">(chapter removed)</span>
{:else}
<span class="target muted">Whole manga</span>
{/if}
<span class="created">
Bookmarked {new Date(b.created_at).toLocaleDateString()}
</span>
</div>
</li>
{/each}
</ul>
<style>
.bookmark-list {
list-style: none;
padding: 0;
margin: 0;
}
.bookmark {
display: grid;
grid-template-columns: 64px 1fr;
gap: var(--space-4);
align-items: start;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 64px;
height: 96px;
object-fit: cover;
border-radius: var(--radius-md);
background: var(--surface);
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
user-select: none;
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
font-size: var(--font-base);
color: var(--text);
}
.title:hover {
color: var(--primary);
}
.target {
font-size: var(--font-sm);
}
.muted {
color: var(--text-muted);
}
.created {
color: var(--text-muted);
font-size: var(--font-xs);
}
</style>

View File

@@ -0,0 +1,337 @@
<script lang="ts" module>
/**
* Working type for a staged page. Owned by the parent so it can
* read/write `pages` via `bind:pages`. The component is responsible
* for `previewUrl` lifecycle (created on add, revoked on remove /
* unmount).
*/
export type PendingPage = {
id: string;
file: File;
error: string | null;
previewUrl: string;
};
</script>
<script lang="ts">
import { onDestroy } from 'svelte';
import { formatBytes, validateImageFile } from '$lib/upload-validation';
import Modal from './Modal.svelte';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import Trash2 from '@lucide/svelte/icons/trash-2';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
let {
pages = $bindable<PendingPage[]>([]),
testidPrefix = 'pages'
}: {
pages?: PendingPage[];
testidPrefix?: string;
} = $props();
let isDragOver = $state(false);
let previewIndex = $state<number | null>(null);
const previewPage = $derived(
previewIndex != null ? pages[previewIndex] ?? null : null
);
function addFiles(files: File[] | FileList) {
const arr = Array.from(files);
const additions: PendingPage[] = arr.map((file) => ({
id: crypto.randomUUID(),
file,
error: validateImageFile(file),
previewUrl: URL.createObjectURL(file)
}));
pages = [...pages, ...additions];
}
function removePage(id: string) {
const idx = pages.findIndex((p) => p.id === id);
if (idx < 0) return;
URL.revokeObjectURL(pages[idx].previewUrl);
pages = pages.filter((p) => p.id !== id);
}
function movePage(id: string, dir: -1 | 1) {
const i = pages.findIndex((p) => p.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= pages.length) return;
const copy = pages.slice();
[copy[i], copy[j]] = [copy[j], copy[i]];
pages = copy;
}
function onPagesInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) addFiles(input.files);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
if (e.dataTransfer?.files) addFiles(e.dataTransfer.files);
}
function onDragOver(e: DragEvent) {
e.preventDefault();
isDragOver = true;
}
function onDragLeave() {
isDragOver = false;
}
function pageLabel(i: number): string {
// Mirror the server's `{nnnn}` storage convention so the visible
// label matches what the file ends up named on disk.
return `Page ${String(i + 1).padStart(3, '0')}`;
}
onDestroy(() => {
// Revoke any outstanding object URLs so the browser can free the
// backing image data. Closing the page would do this eventually
// anyway, but components inside long-lived single-page apps
// benefit from explicit cleanup.
for (const p of pages) URL.revokeObjectURL(p.previewUrl);
});
</script>
<div
class="drop-zone"
class:drag-over={isDragOver}
ondrop={onDrop}
ondragover={onDragOver}
ondragleave={onDragLeave}
role="region"
aria-label="page upload"
data-testid="{testidPrefix}-drop-zone"
>
<UploadCloud size={32} aria-hidden="true" class="drop-icon" />
<p>
Drop pages here, or
<label class="file-link">
browse
<input
type="file"
accept="image/*"
multiple
onchange={onPagesInputChange}
data-testid="{testidPrefix}-input"
/>
</label>
</p>
</div>
{#if pages.length > 0}
<ol class="pages" data-testid="{testidPrefix}-list">
{#each pages as p, i (p.id)}
<li class:invalid={p.error} data-testid="{testidPrefix}-row">
<button
type="button"
class="thumb-btn"
onclick={() => (previewIndex = i)}
aria-label="Preview {pageLabel(i)}"
title="Preview"
data-testid="{testidPrefix}-thumb"
>
<img src={p.previewUrl} alt="" class="thumb" loading="lazy" />
</button>
<div class="page-meta">
<span class="page-label">{pageLabel(i)}</span>
<span class="page-origin" title={p.file.name}>
from {p.file.name} · {formatBytes(p.file.size)}
</span>
</div>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, -1)}
disabled={i === 0}
aria-label="Move {pageLabel(i)} up"
title="Move up"
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, 1)}
disabled={i === pages.length - 1}
aria-label="Move {pageLabel(i)} down"
title="Move down"
>
<ArrowDown size={16} aria-hidden="true" />
</button>
<button
class="icon-btn danger"
type="button"
onclick={() => removePage(p.id)}
aria-label="Remove {pageLabel(i)}"
title="Remove page"
data-testid="{testidPrefix}-remove"
>
<Trash2 size={16} aria-hidden="true" />
</button>
{#if p.error}
<span class="field-error" role="alert">{p.error}</span>
{/if}
</li>
{/each}
</ol>
{/if}
<Modal
open={previewIndex != null}
title={previewPage ? pageLabel(previewIndex ?? 0) : 'Preview'}
onClose={() => (previewIndex = null)}
size="lg"
closeOnBackdrop={true}
testid="page-preview-modal"
>
{#if previewPage}
<img
src={previewPage.previewUrl}
alt={pageLabel(previewIndex ?? 0)}
class="preview-large"
/>
{/if}
</Modal>
<style>
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
border: 2px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: var(--space-6);
text-align: center;
background: var(--surface);
color: var(--text-muted);
transition:
background var(--transition),
border-color var(--transition);
}
.drop-zone :global(.drop-icon) {
color: var(--text-muted);
}
.drop-zone.drag-over {
background: var(--primary-soft-bg);
border-color: var(--primary);
}
.file-link input[type='file'] {
display: none;
}
.file-link {
color: var(--primary);
text-decoration: underline;
cursor: pointer;
}
.pages {
padding: 0;
margin: var(--space-3) 0 0;
list-style: none;
}
.pages li {
display: grid;
grid-template-columns: 56px 1fr auto auto auto;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border);
}
.pages li.invalid {
background: var(--danger-soft-bg);
}
.thumb-btn {
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
line-height: 0;
overflow: hidden;
width: 56px;
height: 80px;
}
.thumb-btn:hover {
border-color: var(--primary);
}
.thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.page-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.page-label {
font-weight: var(--weight-semibold);
color: var(--text);
font-size: var(--font-sm);
}
.page-origin {
color: var(--text-muted);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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);
}
.icon-btn:hover:not(:disabled) {
background: var(--surface-elevated);
color: var(--text);
}
.icon-btn.danger:hover:not(:disabled) {
color: var(--danger);
}
.field-error {
grid-column: 1 / -1;
color: var(--danger);
font-size: var(--font-sm);
}
.preview-large {
display: block;
max-width: 100%;
max-height: 75vh;
margin: 0 auto;
object-fit: contain;
background: var(--surface-elevated);
border-radius: var(--radius-sm);
}
</style>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import type { CollectionSummary } from '$lib/api/collections';
import FolderOpen from '@lucide/svelte/icons/folder-open';
let {
collections
}: {
collections: CollectionSummary[];
} = $props();
</script>
<ul class="grid" data-testid="collections-list">
{#each collections as c (c.id)}
<li class="card">
<a href="/collections/{c.id}" class="cover-link" tabindex="-1" aria-hidden="true">
<div class="collage">
{#if c.sample_covers.length === 0}
<div class="collage-empty">
<FolderOpen size={36} aria-hidden="true" />
</div>
{:else}
{#each c.sample_covers as cover (cover)}
<img
src={fileUrl(cover)}
alt=""
class="collage-cover"
loading="lazy"
/>
{/each}
{/if}
</div>
</a>
<div class="meta">
<a href="/collections/{c.id}" class="name" data-testid={`collection-${c.id}`}>
{c.name}
</a>
<span class="count">
{c.manga_count}
{c.manga_count === 1 ? 'manga' : 'mangas'}
</span>
</div>
</li>
{/each}
</ul>
<style>
.grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-4);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.cover-link {
display: block;
line-height: 0;
}
.collage {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2px;
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.collage-empty {
grid-column: 1 / -1;
grid-row: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.collage-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.collage-cover:only-child {
grid-column: 1 / -1;
grid-row: 1 / -1;
}
.collage-cover:first-child:nth-last-child(2),
.collage-cover:first-child:nth-last-child(2) ~ .collage-cover {
grid-row: 1 / -1;
}
.collage-cover:first-child:nth-last-child(3) {
grid-row: 1 / -1;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--space-1);
}
.name {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name:hover {
color: var(--primary);
text-decoration: none;
}
.count {
color: var(--text-muted);
font-size: var(--font-xs);
}
</style>

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
let {
open,
title,
onClose,
children,
footer,
size = 'md',
closeOnBackdrop = false,
testid
}: {
open: boolean;
title: string;
onClose: () => void;
children: Snippet;
footer?: Snippet;
size?: 'sm' | 'md' | 'lg';
/**
* Whether clicking the dim backdrop closes the modal. Off by
* default — forms with unsaved input would discard typed data
* on a misclick. Opt-in for confirm dialogs and read-only
* popovers.
*/
closeOnBackdrop?: boolean;
testid?: string;
} = $props();
let dialog: HTMLDivElement | undefined = $state();
// Track previous focus so we can restore it on close — a basic
// requirement for any focus-trapping modal.
let previouslyFocused: HTMLElement | null = null;
$effect(() => {
if (open) {
previouslyFocused = document.activeElement as HTMLElement | null;
// Defer until the dialog mounts.
queueMicrotask(() => dialog?.focus());
} else if (previouslyFocused) {
previouslyFocused.focus();
previouslyFocused = null;
}
});
function focusable(): HTMLElement[] {
if (!dialog) return [];
// Standard set of "tab can land here" elements, minus those
// disabled or with `tabindex=-1`. Sufficient for our forms.
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
return Array.from(dialog.querySelectorAll<HTMLElement>(selector));
}
function onKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
return;
}
if (e.key === 'Tab') {
// Wrap focus inside the dialog so Tab/Shift+Tab don't
// escape to the background page.
const items = focusable();
if (items.length === 0) {
e.preventDefault();
dialog?.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !dialog?.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
}
}
onMount(() => {
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
function onBackdropClick(e: MouseEvent) {
if (!closeOnBackdrop) return;
if (e.target === e.currentTarget) onClose();
}
</script>
{#if open}
<div
class="backdrop"
onclick={onBackdropClick}
role="presentation"
data-testid={testid ? `${testid}-backdrop` : undefined}
>
<div
class="dialog size-{size}"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
bind:this={dialog}
data-testid={testid}
>
<header class="header">
<h2 id="modal-title">{title}</h2>
<button
type="button"
class="close"
onclick={onClose}
aria-label="Close"
title="Close"
data-testid={testid ? `${testid}-close` : undefined}
>
<X size={18} aria-hidden="true" />
</button>
</header>
<div class="body">{@render children()}</div>
{#if footer}
<footer class="footer">{@render footer()}</footer>
{/if}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
z-index: var(--z-modal);
}
.dialog {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-height: 90vh;
display: flex;
flex-direction: column;
width: 100%;
outline: none;
}
.size-sm {
max-width: 24rem;
}
.size-md {
max-width: 32rem;
}
.size-lg {
max-width: 48rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.header h2 {
margin: 0;
font-size: var(--font-lg);
}
.close {
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);
}
.close:hover {
background: var(--surface-elevated);
color: var(--text);
}
.body {
padding: var(--space-4);
overflow-y: auto;
}
.footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,33 @@
/**
* Cross-component flag for the reader's "hide all chrome" view.
*
* The reader page toggles this; the root layout reads it to hide the
* top app navbar; the reader itself reads it to hide its own nav bar
* and bottom chapter bar. CSS handles the slide animations via a
* `data-reader-fullscreen` attribute on `<html>` so the entire frame
* (layout chrome included) stays synchronised.
*
* Always reset on reader unmount — letting the flag leak across
* navigation would orphan a hidden app navbar on other pages.
*/
let active = $state(false);
export const readerFullscreen = {
get value() {
return active;
},
set value(v: boolean) {
active = v;
if (typeof document !== 'undefined') {
if (v) document.documentElement.dataset.readerFullscreen = 'true';
else delete document.documentElement.dataset.readerFullscreen;
}
},
toggle() {
this.value = !active;
},
/** Force off — call from the reader's onDestroy. */
reset() {
this.value = false;
}
};

View File

@@ -60,6 +60,15 @@
--icon-md: 18px;
--icon-lg: 22px;
/* App-frame heights (fixed-position bars at the top and bottom of
the viewport). These are first-paint fallbacks — the real
values are written by ResizeObservers on the actual elements
in +layout.svelte and the reader, so they reflect rendered
size and survive font / zoom / wrap changes. */
--app-header-h: 60px;
--reader-nav-h: 56px;
--reader-bar-h: 56px;
--z-dropdown: 10;
--z-sticky: 50;
--z-modal: 100;

View File

@@ -6,18 +6,40 @@
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle';
import Bookmark from '@lucide/svelte/icons/bookmark';
import Settings from '@lucide/svelte/icons/settings';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import LogOut from '@lucide/svelte/icons/log-out';
import '$lib/styles/tokens.css';
let { children } = $props();
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
onMount(() => {
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
// Publish the header's measured height as a CSS custom
// property so sticky descendants (e.g. the reader nav) can
// pin themselves directly below it without guessing. A
// ResizeObserver keeps it in sync as the viewport reflows
// (the nav `flex-wrap: wrap`s on narrow widths), the user
// zooms, or fonts swap. Hard-coded pixel offsets in tokens
// are wrong in principle — actual height varies with all
// of the above.
if (!headerEl) return;
const publish = () => {
document.documentElement.style.setProperty(
'--app-header-h',
`${headerEl!.offsetHeight}px`
);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(headerEl);
return () => ro.disconnect();
});
// Pull fresh server preferences whenever the user changes (login,
@@ -45,27 +67,31 @@
}
</script>
<header>
<header bind:this={headerEl}>
<nav aria-label="primary">
<a class="brand" href="/">Mangalord</a>
<a class="nav-link" href="/upload">
<Upload size={18} aria-hidden="true" />
<span>Upload</span>
</a>
<a class="nav-link" href="/profile" data-testid="nav-profile">
<UserCircle size={18} aria-hidden="true" />
<span>Profile</span>
</a>
<a class="nav-link" href="/bookmarks">
<Bookmark size={18} aria-hidden="true" />
<span>Bookmarks</span>
</a>
<a class="nav-link" href="/collections">
<FolderOpen size={18} aria-hidden="true" />
<span>Collections</span>
</a>
</nav>
<div class="session" data-testid="session-area">
{#if !session.loaded}
<span data-testid="session-loading" aria-busy="true"></span>
{:else if session.user}
<span class="username" data-testid="session-user">{session.user.username}</span>
<a class="nav-link" href="/settings" data-testid="nav-settings">
<Settings size={18} aria-hidden="true" />
<span>Settings</span>
</a>
<button
class="icon-btn"
type="button"
@@ -144,6 +170,24 @@
font-size: var(--font-sm);
}
/* App frame: header is fixed at the viewport top with a slide
transition so reader fullscreen (set via `data-reader-fullscreen`
on `<html>`) can hide it without jolting the layout. `main` pays
the gap with a matching padding-top that animates in lockstep. */
header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-sticky);
transform: translateY(0);
transition: transform 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) header {
transform: translateY(-100%);
}
.session {
display: flex;
align-items: center;
@@ -181,7 +225,19 @@
main {
padding: var(--space-4);
/* Reserve room for the fixed header so its presence doesn't
overlap content. The header height comes from a runtime
ResizeObserver (see onMount above) so this always tracks
the rendered size. */
padding-top: calc(var(--app-header-h) + var(--space-4));
max-width: 64rem;
margin: 0 auto;
transition: padding-top 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) main {
/* No top reservation in focus mode — the chapter image runs
edge-to-edge once the header has slid off. */
padding-top: 0;
}
</style>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import BookImage from '@lucide/svelte/icons/book-image';
import BookmarkList from '$lib/components/BookmarkList.svelte';
let { data } = $props();
const authenticated = $derived(data.authenticated);
@@ -25,122 +24,10 @@
{:else if bookmarks.length === 0}
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
{:else}
<ul class="bookmark-list" data-testid="bookmark-list">
{#each bookmarks as b (b.id)}
<li class="bookmark">
<a href="/manga/{b.manga_id}" class="cover-link" aria-hidden="true" tabindex="-1">
{#if b.manga_cover_image_path}
<img
src={fileUrl(b.manga_cover_image_path)}
alt=""
class="cover"
loading="lazy"
/>
{:else}
<div class="cover cover-placeholder">
<BookImage size={22} aria-hidden="true" />
</div>
{/if}
</a>
<div class="meta">
<a
href="/manga/{b.manga_id}"
class="title"
data-testid="bookmark-title"
>
{b.manga_title ?? 'Unknown manga'}
</a>
{#if b.chapter_id && b.chapter_number != null}
<a
href="/manga/{b.manga_id}/chapter/{b.chapter_number}"
class="target"
>
Chapter {b.chapter_number}{#if b.page != null && b.page > 0} — page {b.page}{/if}
</a>
{:else if b.chapter_id}
<!-- Chapter bookmark whose chapter was deleted;
chapter_id != null but chapter_number == null
because the LEFT JOIN found nothing. -->
<span class="target muted">(chapter removed)</span>
{:else}
<span class="target muted">Whole manga</span>
{/if}
<span class="created">
Bookmarked {new Date(b.created_at).toLocaleDateString()}
</span>
</div>
</li>
{/each}
</ul>
<BookmarkList {bookmarks} />
{/if}
<style>
.bookmark-list {
list-style: none;
padding: 0;
margin: 0;
}
.bookmark {
display: grid;
grid-template-columns: 64px 1fr;
gap: var(--space-4);
align-items: start;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 64px;
height: 96px;
object-fit: cover;
border-radius: var(--radius-md);
background: var(--surface);
}
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
user-select: none;
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
font-size: var(--font-base);
color: var(--text);
}
.title:hover {
color: var(--primary);
}
.target {
font-size: var(--font-sm);
}
.muted {
color: var(--text-muted);
}
.created {
color: var(--text-muted);
font-size: var(--font-xs);
}
.error {
color: var(--danger);
}

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
let { data } = $props();
const collections = $derived(data.collections);
</script>
<svelte:head>
<title>Collections — Mangalord</title>
</svelte:head>
<h1>Collections</h1>
{#if !data.authenticated}
<p class="status">
<a href="/login">Sign in</a> to see and manage your collections.
</p>
{:else if data.error}
<p class="error" role="alert">{data.error}</p>
{:else if collections.length === 0}
<p class="status" data-testid="collections-empty">
You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one.
</p>
{:else}
<CollectionsGrid {collections} />
{/if}
<style>
.status {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
</style>

View File

@@ -0,0 +1,20 @@
import { ApiError } from '$lib/api/client';
import { listMyCollections } from '$lib/api/collections';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async () => {
try {
const page = await listMyCollections({ limit: 200 });
return { collections: page.items, authenticated: true, error: null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { collections: [], authenticated: false, error: null };
}
if (e instanceof ApiError) {
return { collections: [], authenticated: true, error: e.message };
}
throw e;
}
};

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
deleteCollection,
removeMangaFromCollection,
updateCollection
} from '$lib/api/collections';
import type { Manga } from '$lib/api/client';
import MangaCard from '$lib/components/MangaCard.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
import Check from '@lucide/svelte/icons/check';
import Trash2 from '@lucide/svelte/icons/trash-2';
import X from '@lucide/svelte/icons/x';
let { data } = $props();
// svelte-ignore state_referenced_locally
let collection = $state({ ...data.collection });
// svelte-ignore state_referenced_locally
let mangas = $state<Manga[]>([...data.mangas]);
let editing = $state(false);
let editName = $state('');
let editDescription = $state('');
let editError: string | null = $state(null);
let editBusy = $state(false);
function startEdit() {
editName = collection.name;
editDescription = collection.description ?? '';
editError = null;
editing = true;
}
async function saveEdit() {
if (editBusy) return;
editBusy = true;
editError = null;
try {
const updated = await updateCollection(collection.id, {
name: editName.trim(),
description: editDescription.trim() || null
});
collection = updated;
editing = false;
} catch (e) {
editError = (e as Error).message;
} finally {
editBusy = false;
}
}
async function onDeleteCollection() {
if (!confirm(`Delete collection "${collection.name}"? This cannot be undone.`)) {
return;
}
try {
await deleteCollection(collection.id);
goto('/collections');
} catch (e) {
editError = (e as Error).message;
}
}
async function onRemoveManga(m: Manga) {
const snapshot = mangas;
mangas = mangas.filter((x) => x.id !== m.id);
try {
await removeMangaFromCollection(collection.id, m.id);
} catch (e) {
mangas = snapshot;
editError = (e as Error).message;
}
}
</script>
<svelte:head>
<title>{collection.name} — Mangalord</title>
</svelte:head>
<nav class="back">
<a href="/collections" class="back-link">
<ArrowLeft size={16} aria-hidden="true" />
<span>All collections</span>
</a>
</nav>
<header class="overview">
{#if editing}
<form
class="edit-form"
onsubmit={(e) => {
e.preventDefault();
void saveEdit();
}}
action="javascript:void(0)"
>
<input
type="text"
bind:value={editName}
maxlength="64"
required
aria-label="Collection name"
data-testid="collection-edit-name"
/>
<textarea
bind:value={editDescription}
rows="2"
maxlength="1024"
placeholder="Description (optional)"
aria-label="Collection description"
data-testid="collection-edit-description"
></textarea>
<div class="edit-actions">
<button
type="submit"
class="primary"
disabled={!editName.trim() || editBusy}
data-testid="collection-edit-save"
>
<Check size={14} aria-hidden="true" />
<span>Save</span>
</button>
<button type="button" onclick={() => (editing = false)} disabled={editBusy}>
<X size={14} aria-hidden="true" />
<span>Cancel</span>
</button>
</div>
</form>
{:else}
<div class="title-row">
<h1 data-testid="collection-name">{collection.name}</h1>
<button
type="button"
class="icon-btn"
onclick={startEdit}
aria-label="Edit collection"
title="Edit"
data-testid="collection-edit-open"
>
<Pencil size={16} aria-hidden="true" />
</button>
<button
type="button"
class="icon-btn danger"
onclick={onDeleteCollection}
aria-label="Delete collection"
title="Delete"
data-testid="collection-delete"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
{#if collection.description}
<p class="description" data-testid="collection-description">
{collection.description}
</p>
{/if}
{/if}
{#if editError}
<p class="error" role="alert">{editError}</p>
{/if}
</header>
{#if mangas.length === 0}
<p class="status" data-testid="collection-empty">
This collection is empty.
</p>
{:else}
<ul class="manga-grid" data-testid="collection-manga-list">
{#each mangas as m (m.id)}
<li class="card-with-remove">
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
<button
type="button"
class="remove"
onclick={() => onRemoveManga(m)}
aria-label={`Remove ${m.title} from collection`}
title="Remove from collection"
data-testid={`collection-remove-manga-${m.id}`}
>
<X size={14} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
{/if}
<style>
.back {
margin-bottom: var(--space-3);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-sm);
}
.overview {
margin-bottom: var(--space-5);
}
.title-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.title-row h1 {
margin: 0;
flex: 1;
}
.description {
color: var(--text-muted);
margin: var(--space-2) 0 0;
white-space: pre-wrap;
}
.edit-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.edit-actions {
display: flex;
gap: var(--space-2);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.error {
color: var(--danger);
margin: var(--space-2) 0 0;
}
.status {
color: var(--text-muted);
}
.manga-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
.card-with-remove {
position: relative;
list-style: none;
}
.remove {
position: absolute;
top: var(--space-1);
right: var(--space-1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
border: 0;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition);
}
.card-with-remove:hover .remove,
.card-with-remove:focus-within .remove {
opacity: 1;
}
.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);
}
.icon-btn:hover {
background: var(--surface-elevated);
color: var(--text);
}
.icon-btn.danger:hover {
color: var(--danger);
}
</style>

View File

@@ -0,0 +1,40 @@
import { error, redirect } from '@sveltejs/kit';
import { ApiError } from '$lib/api/client';
import {
getCollection,
listCollectionMangas
} from '$lib/api/collections';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, url }) => {
try {
const [collection, mangas] = await Promise.all([
getCollection(params.id),
listCollectionMangas(params.id, { limit: 200 })
]);
return {
collection,
mangas: mangas.items,
total: mangas.page.total
};
} catch (e) {
if (e instanceof ApiError) {
// 401 means the user's session is gone — bounce to login
// and preserve where they wanted to go.
if (e.status === 401) {
const next = encodeURIComponent(url.pathname);
redirect(302, `/login?next=${next}`);
}
// 403 (post-Phase-3-polish the backend collapses this to
// 404 already, but keep the branch for defense-in-depth)
// and 404 both render the standard not-found page so the
// URL doesn't disclose collection existence to non-owners.
if (e.status === 404 || e.status === 403) {
error(404, 'Collection not found');
}
}
throw e;
}
};

View File

@@ -11,11 +11,28 @@
import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus';
import FolderPlus from '@lucide/svelte/icons/folder-plus';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
let { data } = $props();
const manga = $derived(data.manga);
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 genres = $derived<GenreRef[]>(manga.genres);
@@ -148,6 +165,8 @@
}
const statusLabel = $derived(manga.status === 'completed' ? 'Completed' : 'Ongoing');
let collectionModalOpen = $state(false);
</script>
<svelte:head>
@@ -284,27 +303,69 @@
{/if}
{#if session.user}
<button
type="button"
class="bookmark"
class:active={mangaBookmark}
onclick={toggleBookmark}
disabled={busy}
aria-pressed={mangaBookmark ? 'true' : 'false'}
data-testid="bookmark-toggle"
>
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
</button>
<div class="action-row">
<button
type="button"
class="action"
class:active={mangaBookmark}
onclick={toggleBookmark}
disabled={busy}
aria-pressed={mangaBookmark ? 'true' : 'false'}
data-testid="bookmark-toggle"
>
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
</button>
<button
type="button"
class="action"
onclick={() => (collectionModalOpen = true)}
data-testid="add-to-collection-open"
>
<FolderPlus size={16} aria-hidden="true" />
<span>Add to collection</span>
</button>
<a
class="action"
href="/manga/{manga.id}/upload-chapter"
data-testid="upload-chapter-link"
>
<UploadCloud size={16} aria-hidden="true" />
<span>Upload chapter</span>
</a>
</div>
{:else}
<a class="bookmark" href="/login" data-testid="bookmark-signin">
Sign in to bookmark
<a class="action" href="/login" data-testid="bookmark-signin">
Sign in to bookmark or collect
</a>
{/if}
</div>
</header>
{#if session.user}
<AddToCollectionModal
open={collectionModalOpen}
mangaId={manga.id}
onClose={() => (collectionModalOpen = false)}
/>
{/if}
<section aria-label="chapters">
<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}
<p data-testid="chapters-empty">No chapters yet.</p>
{:else}
@@ -475,11 +536,17 @@
font-size: var(--font-sm);
}
.bookmark {
.action-row {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
flex-wrap: wrap;
}
.action {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
border: 1px solid var(--border-strong);
@@ -496,17 +563,47 @@
color var(--transition);
}
.bookmark:hover {
.action:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.bookmark.active {
.action.active {
background: var(--warning-soft-bg);
border-color: var(--warning-border);
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 {
padding-left: var(--space-6);
color: var(--text);

View File

@@ -1,15 +1,23 @@
import { getManga } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, chapters, bookmarks] = await Promise.all([
const [manga, chapters, bookmarks, readProgress] = await Promise.all([
getManga(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

@@ -1,19 +1,26 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { fileUrl } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import BookImage from '@lucide/svelte/icons/book-image';
import FileText from '@lucide/svelte/icons/file-text';
import ScrollText from '@lucide/svelte/icons/scroll-text';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
import Minimize2 from '@lucide/svelte/icons/minimize-2';
let { data } = $props();
const manga = $derived(data.manga);
const chapter = $derived(data.chapter);
const pages = $derived(data.pages);
const chapters = $derived(data.chapters);
const mode = $derived(preferences.readerMode);
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
@@ -24,14 +31,138 @@
: `${manga.title} — Ch. ${chapter.number}`
);
let index = $state(0);
// Prev/next chapter computed from the chapter list. listChapters
// returns chapters in number ASC order; we still resolve via find
// rather than index because the current chapter's position may
// not be `chapter.number - 1` (sparse numbering / chapter 0.5 /
// future skipped numbers).
const sortedChapters = $derived(
[...chapters].sort((a, b) => a.number - b.number)
);
const currentIdx = $derived(
sortedChapters.findIndex((c) => c.id === chapter.id)
);
const prevChapter = $derived(
currentIdx > 0 ? sortedChapters[currentIdx - 1] : null
);
const nextChapter = $derived(
currentIdx >= 0 && currentIdx < sortedChapters.length - 1
? sortedChapters[currentIdx + 1]
: null
);
// Seed the initial page index from `?page=`. Numeric values are
// 1-indexed and clamped to the chapter's page count; the sentinel
// `last` lands on the final page (used by the prev-chapter chevron
// when going backwards through the series). Component remounts on
// chapter navigation, so this only runs at the start of each
// chapter — referencing `data` here is intentional.
// svelte-ignore state_referenced_locally
const initialIndex = (() => {
const req = data.requestedPage;
if (req === 'last') return Math.max(0, data.pages.length - 1);
if (typeof req === 'number') {
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
}
return 0;
})();
let index = $state(initialIndex);
let continuousPageEls: HTMLImageElement[] = $state([]);
let chapterBarEl: HTMLElement | undefined = $state();
let readerNavEl: HTMLElement | undefined = $state();
// Publish the reader nav's actual measured height. Sticky
// positioning had a "settle on scroll" effect: the bar's natural
// position sat 16px below the app header (main's space-4 padding),
// and only docked against the header once the user scrolled
// enough to consume that gap. The fix is to lift the bar out of
// document flow entirely (position: fixed) and reserve space in
// the chapter content via `--reader-nav-h`.
$effect(() => {
if (!readerNavEl) return;
const publish = () => {
document.documentElement.style.setProperty(
'--reader-nav-h',
`${readerNavEl!.offsetHeight}px`
);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(readerNavEl);
return () => {
ro.disconnect();
document.documentElement.style.removeProperty('--reader-nav-h');
};
});
// Publish the bottom chapter-bar's actual measured height so the
// continuous container's `padding-bottom` exactly matches it. Same
// rationale as the layout header's measurement: the bar's height
// changes with font size, padding, browser zoom, and content
// wrap on narrow viewports — a single hard-coded number is wrong.
// Only present in continuous mode, so the effect only runs there
// and cleans up when the bar unmounts on mode toggle.
$effect(() => {
if (mode !== 'continuous' || !chapterBarEl) return;
const publish = () => {
document.documentElement.style.setProperty(
'--reader-bar-h',
`${chapterBarEl!.offsetHeight}px`
);
};
publish();
const ro = new ResizeObserver(publish);
ro.observe(chapterBarEl);
return () => {
ro.disconnect();
// Clear so other pages (single mode, /upload, etc.) don't
// inherit a stale reservation.
document.documentElement.style.removeProperty('--reader-bar-h');
};
});
// ---- Navigation ----
//
// In single mode: page-by-page within the chapter, falling through
// to the adjacent chapter at the boundaries.
// In continuous mode: chevrons / arrow keys jump straight to the
// adjacent chapter — there is no in-page concept when everything
// is stacked.
function jumpToPrevChapter() {
if (!prevChapter) return;
// Land on the LAST page of the previous chapter so the back-
// navigation feels continuous in single mode. Harmless in
// continuous mode (the reader just shows everything).
const target = mode === 'single' ? `?page=last` : '';
void goto(`/manga/${manga.id}/chapter/${prevChapter.number}${target}`);
}
function jumpToNextChapter() {
if (!nextChapter) return;
void goto(`/manga/${manga.id}/chapter/${nextChapter.number}`);
}
function next() {
if (index < pages.length - 1) index += 1;
if (mode === 'single') {
if (index < pages.length - 1) {
index += 1;
return;
}
jumpToNextChapter();
} else {
jumpToNextChapter();
}
}
function prev() {
if (index > 0) index -= 1;
if (mode === 'single') {
if (index > 0) {
index -= 1;
return;
}
jumpToPrevChapter();
} else {
jumpToPrevChapter();
}
}
function first() {
index = 0;
@@ -40,22 +171,56 @@
index = pages.length - 1;
}
// Whether the prev/next chevron should be enabled. Always render
// the button — disabling rather than hiding gives the boundary a
// shape so users can tell they're at the edge.
const canPrev = $derived(
mode === 'single' ? index > 0 || prevChapter !== null : prevChapter !== null
);
const canNext = $derived(
mode === 'single'
? index < pages.length - 1 || nextChapter !== null
: nextChapter !== null
);
function onKeydown(e: KeyboardEvent) {
// 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;
// In continuous mode, native scrolling handles Space/PageDown/arrows;
// we still wire Home/End to scrollIntoView so jumping to the chapter
// bounds stays a one-keypress action.
// 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.
if (e.key === 'Escape' && readerFullscreen.value) {
e.preventDefault();
readerFullscreen.value = false;
return;
}
// In continuous mode, native scrolling handles Space / PageDown /
// Up / Down; Left + Right (and the vim j/k aliases) jump
// chapters because there's no in-page concept here. Home / End
// still scroll to chapter bounds via scrollIntoView.
if (mode === 'continuous') {
if (e.key === 'Home') {
e.preventDefault();
continuousPageEls[0]?.scrollIntoView({ block: 'start' });
} else if (e.key === 'End') {
e.preventDefault();
continuousPageEls[continuousPageEls.length - 1]?.scrollIntoView({
block: 'end'
});
switch (e.key) {
case 'ArrowLeft':
case 'k':
e.preventDefault();
jumpToPrevChapter();
break;
case 'ArrowRight':
case 'j':
e.preventDefault();
jumpToNextChapter();
break;
case 'Home':
e.preventDefault();
continuousPageEls[0]?.scrollIntoView({ block: 'start' });
break;
case 'End':
e.preventDefault();
continuousPageEls[
continuousPageEls.length - 1
]?.scrollIntoView({ block: 'end' });
break;
}
return;
}
@@ -90,13 +255,166 @@
onDestroy(() => {
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);
}
// Don't let the fullscreen flag leak to non-reader pages —
// otherwise the layout header would stay slid-off on /upload
// or /profile after navigating away.
readerFullscreen.reset();
// 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>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<nav class="reader-nav" aria-label="reader">
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
<a href="/manga/{manga.id}" class="back" data-testid="back-to-manga">
<ArrowLeft size={18} aria-hidden="true" />
{#if manga.cover_image_path}
@@ -157,6 +475,7 @@
<option value="large">Large gap</option>
</select>
</label>
{/if}
</div>
@@ -167,8 +486,39 @@
{pages.length} {pages.length === 1 ? 'page' : 'pages'}
{/if}
</span>
<button
type="button"
class="fullscreen-toggle"
onclick={() => readerFullscreen.toggle()}
aria-label="Enter focus mode (hide top + bottom bars)"
title="Focus mode"
data-testid="reader-fullscreen-toggle"
>
<Maximize2 size={16} aria-hidden="true" />
<span>Focus</span>
</button>
</nav>
<!--
Floating exit affordance — only rendered while focus mode is on.
Lives in the top-right corner with a low resting opacity so it
doesn't distract from the page, but is reachable with a quick
glance + click. Esc also exits.
-->
{#if readerFullscreen.value}
<button
type="button"
class="fullscreen-exit"
onclick={() => (readerFullscreen.value = false)}
aria-label="Exit focus mode (or press Esc)"
title="Exit focus mode (Esc)"
data-testid="reader-fullscreen-exit"
>
<Minimize2 size={16} aria-hidden="true" />
</button>
{/if}
{#if pages.length === 0}
<p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
{:else if mode === 'single'}
@@ -177,8 +527,12 @@
type="button"
class="nav prev"
onclick={prev}
disabled={index === 0}
aria-label="Previous page"
disabled={!canPrev}
aria-label={index === 0
? prevChapter
? `Previous chapter (${prevChapter.number})`
: 'Previous chapter'
: 'Previous page'}
data-testid="reader-prev"
>
<ChevronLeft size={22} aria-hidden="true" />
@@ -196,8 +550,12 @@
type="button"
class="nav next"
onclick={next}
disabled={index === pages.length - 1}
aria-label="Next page"
disabled={!canNext}
aria-label={index === pages.length - 1
? nextChapter
? `Next chapter (${nextChapter.number})`
: 'Next chapter'
: 'Next page'}
data-testid="reader-next"
>
<ChevronRight size={22} aria-hidden="true" />
@@ -229,18 +587,80 @@
/>
{/each}
</div>
<!-- Sticky bottom bar — always visible at the foot of the viewport
in continuous mode. Slides off when full-screen is toggled. -->
<div
class="chapter-bar"
bind:this={chapterBarEl}
data-testid="chevrons-inline-bottom"
>
<button
type="button"
class="inline-btn"
onclick={jumpToPrevChapter}
disabled={!prevChapter}
data-testid="chapter-bar-prev"
>
<ChevronLeft size={16} aria-hidden="true" />
<span>
{prevChapter
? `Previous chapter (Ch. ${prevChapter.number})`
: 'No previous chapter'}
</span>
</button>
<span class="chapter-bar-current" aria-hidden="true">
Ch. {chapter.number}{#if chapter.title}{chapter.title}{/if}
</span>
<button
type="button"
class="inline-btn"
onclick={jumpToNextChapter}
disabled={!nextChapter}
data-testid="chapter-bar-next"
>
<span>
{nextChapter
? `Next chapter (Ch. ${nextChapter.number})`
: 'No next chapter'}
</span>
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
{/if}
<style>
/* Pinned to the viewport directly below the (also fixed) layout
header. `position: fixed` rather than `sticky` because the
latter would have a "settle on scroll" period until its natural
position consumes main's padding-top. Chapter content reserves
room for this bar via `--reader-nav-h` (measured at runtime by
a ResizeObserver in onMount). Focus mode slides it up
off-screen via transform — the fixed origin keeps the slide
distance equal to the bar's height regardless of scroll. */
.reader-nav {
position: fixed;
top: var(--app-header-h);
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: var(--space-2);
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-3);
background: var(--bg);
gap: var(--space-3);
flex-wrap: wrap;
transition:
transform 220ms ease-out,
opacity 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .reader-nav {
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
}
.back {
@@ -359,17 +779,37 @@
color: var(--text-muted);
}
/* Reserve room for the fixed reader-nav above so the first page
image (single mode) and the top of the chapter stack
(continuous mode) aren't hidden behind it. The variable is
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. */
.page-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--space-2);
align-items: center;
padding-top: var(--reader-nav-h);
transition: padding-top 220ms ease-out;
}
.continuous {
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--reader-nav-h);
transition:
padding-top 220ms ease-out,
padding-bottom 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .page-wrap,
:global(html[data-reader-fullscreen='true']) .continuous {
padding-top: 0;
}
:global(html[data-reader-fullscreen='true']) .continuous {
padding-bottom: 0;
}
.page-image {
@@ -404,6 +844,131 @@
border-color var(--transition);
}
/* ===== Continuous-mode chapter bar (sticky bottom) =====
Fixed at the viewport bottom so chapter-jump controls stay one
click away no matter how far the user has scrolled. Slides
offscreen in focus mode via the same translate trick as the
reader nav above. */
.chapter-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--z-sticky);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--surface);
border-top: 1px solid var(--border);
min-height: var(--reader-bar-h);
transform: translateY(0);
transition:
transform 220ms ease-out,
opacity 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .chapter-bar {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
.chapter-bar-current {
color: var(--text-muted);
font-size: var(--font-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex-shrink: 1;
}
.inline-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: transparent;
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-sm);
min-height: 36px;
white-space: nowrap;
}
.inline-btn:hover:not(:disabled) {
background: var(--surface-elevated);
border-color: var(--primary);
}
.inline-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Leave room above the fixed bottom bar so the last image isn't
hidden when the user scrolls to the chapter end. */
.continuous {
padding-bottom: calc(var(--reader-bar-h) + var(--space-3));
}
/* ===== Focus-mode controls ===== */
.fullscreen-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 32px;
background: var(--surface);
color: var(--text-muted);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-xs);
}
.fullscreen-toggle:hover {
background: var(--surface-elevated);
color: var(--text);
border-color: var(--primary);
}
/* Small floating exit affordance — corner-pinned, low resting
opacity so it doesn't sit on the chapter image too aggressively
but is still findable without hover. */
.fullscreen-exit {
position: fixed;
top: var(--space-3);
right: var(--space-3);
z-index: var(--z-modal);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.4;
cursor: pointer;
transition:
opacity var(--transition),
background var(--transition);
}
.fullscreen-exit:hover,
.fullscreen-exit:focus-visible {
opacity: 1;
background: var(--surface-elevated);
}
.nav:hover:not(:disabled) {
background: var(--surface-elevated);
border-color: var(--primary);

View File

@@ -1,15 +1,44 @@
import { getManga } from '$lib/api/mangas';
import { getChapter, getChapterPages } from '$lib/api/chapters';
import { getChapter, getChapterPages, listChapters } from '$lib/api/chapters';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
export const load: PageLoad = async ({ params, url }) => {
const number = Number(params.n);
const [manga, chapter, pages] = await Promise.all([
const [manga, chapter, pages, readProgress, chapterList] = await Promise.all([
getManga(params.id),
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.
getMyReadProgressForManga(params.id),
// Loaded so the reader can compute prev/next chapter for the
// chevron-driven chapter navigation. limit=200 covers every
// realistic series; mangas with more chapters will lose some
// chapter-jump precision at the tail edge but the in-page
// navigation still works fine.
listChapters(params.id, { limit: 200 })
]);
return { manga, chapter, pages };
return {
manga,
chapter,
pages,
readProgress,
chapters: chapterList.items,
// `?page=N` lets the prev-chapter chevron land directly on the
// last page of the chapter it just navigated to. `last` is a
// convenience sentinel for "however many pages this chapter
// has"; numeric values are clamped to the chapter's page
// count in the component.
requestedPage: parsePageQuery(url.searchParams.get('page'))
};
};
function parsePageQuery(raw: string | null): number | 'last' | null {
if (!raw) return null;
if (raw === 'last') return 'last';
const n = Number(raw);
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
}

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { createChapter } from '$lib/api/chapters';
import { session } from '$lib/session.svelte';
import ChapterPagesEditor, {
type PendingPage
} from '$lib/components/ChapterPagesEditor.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import BookImage from '@lucide/svelte/icons/book-image';
let { data } = $props();
const manga = $derived(data.manga);
// svelte-ignore state_referenced_locally
let number = $state<number | null>(data.defaultNumber);
let title = $state('');
let pages = $state<PendingPage[]>([]);
let submitting = $state(false);
let error: string | null = $state(null);
const allPagesValid = $derived(pages.every((p) => !p.error));
const canSubmit = $derived(
Boolean(session.user) &&
number != null &&
number >= 1 &&
pages.length > 0 &&
allPagesValid &&
!submitting
);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit || number == null) return;
submitting = true;
error = null;
try {
const created = await createChapter(
manga.id,
{ number, title: title.trim() || null },
pages.map((p) => p.file)
);
// Land on the chapter list — the new chapter is at the
// bottom in chapter-number order; uploader-friendly.
await goto(`/manga/${manga.id}`);
void created;
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
await goto(`/login?next=/manga/${manga.id}/upload-chapter`);
return;
}
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Upload chapter — {manga.title} — Mangalord</title>
</svelte:head>
<nav class="back">
<a href="/manga/{manga.id}" class="back-link">
<ArrowLeft size={16} aria-hidden="true" />
<span>Back to {manga.title}</span>
</a>
</nav>
<header class="header">
<div class="cover">
{#if manga.cover_image_path}
<img
src={fileUrl(manga.cover_image_path)}
alt=""
class="cover-img"
loading="lazy"
/>
{:else}
<span class="cover-placeholder" aria-hidden="true">
<BookImage size={22} aria-hidden="true" />
</span>
{/if}
</div>
<div>
<h1>Upload chapter</h1>
<p class="subtitle">to <strong>{manga.title}</strong></p>
</div>
</header>
{#if !session.loaded}
<p class="status">Loading…</p>
{:else if !session.user}
<p class="status">
<a href="/login?next=/manga/{manga.id}/upload-chapter">Sign in</a>
to upload chapters.
</p>
{:else}
<form
class="card"
onsubmit={submit}
action="javascript:void(0)"
data-testid="upload-chapter-form"
>
<label class="form-field">
<span>Chapter number <span aria-hidden="true">*</span></span>
<input
type="number"
min="1"
step="1"
bind:value={number}
required
data-testid="chapter-number"
/>
</label>
<label class="form-field">
<span>Title (optional)</span>
<input
type="text"
bind:value={title}
maxlength="200"
data-testid="chapter-title"
/>
</label>
<ChapterPagesEditor bind:pages />
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="chapter-submit"
>
{submitting ? 'Uploading…' : 'Upload chapter'}
</button>
{#if error}
<p role="alert" class="form-error" data-testid="chapter-error">{error}</p>
{/if}
</form>
{/if}
<style>
.back {
margin-bottom: var(--space-3);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-sm);
}
.header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.cover {
flex-shrink: 0;
}
.cover-img {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 72px;
background: var(--surface);
color: var(--text-muted);
border-radius: var(--radius-sm);
}
h1 {
margin: 0 0 var(--space-1);
}
.subtitle {
color: var(--text-muted);
margin: 0;
}
.status {
color: var(--text-muted);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
align-self: flex-start;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.form-error {
color: var(--danger);
}
</style>

View File

@@ -0,0 +1,36 @@
import { error, redirect } from '@sveltejs/kit';
import { ApiError } from '$lib/api/client';
import { getManga } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, url }) => {
try {
// Need the manga (so we can show title + cover for context)
// and existing chapters so we can default the chapter-number
// field to "next available" — saves the uploader a click.
const [manga, chapters] = await Promise.all([
getManga(params.id),
listChapters(params.id, { limit: 200 })
]);
// The chapter list endpoint sorts by number ASC; the next
// suggested number is one more than the largest. 200 covers
// every realistic series; users with more chapters can edit
// the number manually.
const maxNumber = chapters.items.reduce(
(max, c) => Math.max(max, c.number),
0
);
return { manga, defaultNumber: maxNumber + 1 };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
redirect(302, `/login?next=${encodeURIComponent(url.pathname)}`);
}
if (e instanceof ApiError && e.status === 404) {
error(404, 'Manga not found');
}
throw e;
}
};

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import { page } from '$app/stores';
import { session } from '$lib/session.svelte';
import User from '@lucide/svelte/icons/user';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import KeyRound from '@lucide/svelte/icons/key-round';
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import History from '@lucide/svelte/icons/history';
let { children } = $props();
type Tab = {
href: string;
label: string;
icon: typeof User;
testid: string;
/** Subroutes that genuinely have nothing for guests are hidden
* until the user signs in. Preferences is shown to everyone
* because theme + reader settings work device-locally. */
guestVisible: boolean;
};
const tabs: Tab[] = [
{ href: '/profile', label: 'Overview', icon: User, testid: 'tab-overview', 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/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', 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(
tabs.filter((t) => session.user || t.guestVisible)
);
</script>
<svelte:head>
<title>Profile — Mangalord</title>
</svelte:head>
<header class="profile-header">
<h1>Profile</h1>
{#if !session.loaded}
<p class="welcome" data-testid="profile-loading">Loading session…</p>
{:else if session.user}
<p class="welcome">
Signed in as <strong>{session.user.username}</strong>
</p>
{:else}
<p class="welcome" data-testid="profile-guest">
Browsing as a guest — sign in to access bookmarks, collections,
and account settings.
</p>
{/if}
</header>
<nav class="tabs" aria-label="Profile sections">
{#each visibleTabs as t (t.href)}
{@const active =
t.href === '/profile'
? $page.url.pathname === '/profile'
: $page.url.pathname === t.href ||
$page.url.pathname.startsWith(t.href + '/')}
<a
class="tab"
class:active
href={t.href}
aria-current={active ? 'page' : undefined}
data-testid={t.testid}
>
<t.icon size={16} aria-hidden="true" />
<span>{t.label}</span>
</a>
{/each}
</nav>
<section class="content">
{@render children()}
</section>
<style>
.profile-header {
margin-bottom: var(--space-3);
}
.profile-header h1 {
margin: 0 0 var(--space-1);
}
.welcome {
color: var(--text-muted);
margin: 0;
font-size: var(--font-sm);
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
color: var(--text-muted);
font-size: var(--font-sm);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition:
color var(--transition),
border-color var(--transition);
}
.tab:hover {
color: var(--text);
text-decoration: none;
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: var(--weight-medium);
}
.content {
margin-top: var(--space-3);
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { session } from '$lib/session.svelte';
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import Calendar from '@lucide/svelte/icons/calendar';
let { data } = $props();
const authenticated = $derived(data.authenticated);
const bookmarkCount = $derived(data.bookmark_count);
const collectionCount = $derived(data.collection_count);
const memberSince = $derived(
session.user
? new Date(session.user.created_at).toLocaleDateString()
: null
);
</script>
{#if !authenticated}
<p class="status" data-testid="profile-signin">
<a href="/login?next=/profile">Sign in</a> to see your bookmarks,
collections, and reading history.
</p>
{:else}
<div class="overview">
<div class="card" data-testid="overview-member">
<Calendar size={22} aria-hidden="true" />
<div>
<div class="label">Member since</div>
<div class="value">{memberSince}</div>
</div>
</div>
<a class="card link" href="/profile/bookmarks" data-testid="overview-bookmarks">
<Bookmark size={22} aria-hidden="true" />
<div>
<div class="label">Bookmarks</div>
<div class="value">{bookmarkCount}</div>
</div>
</a>
<a class="card link" href="/profile/collections" data-testid="overview-collections">
<FolderOpen size={22} aria-hidden="true" />
<div>
<div class="label">Collections</div>
<div class="value">{collectionCount}</div>
</div>
</a>
</div>
<p class="hint">
Use the tabs above to manage your preferences, account, bookmarks, and collections.
</p>
{/if}
<style>
.status {
color: var(--text-muted);
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.card {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-3);
color: var(--text);
text-decoration: none;
}
.card.link:hover {
background: var(--surface-elevated);
border-color: var(--primary);
text-decoration: none;
}
.label {
color: var(--text-muted);
font-size: var(--font-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.value {
font-size: var(--font-xl);
font-weight: var(--weight-semibold);
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
</style>

View File

@@ -0,0 +1,30 @@
import { ApiError } from '$lib/api/client';
import { listMyBookmarks } from '$lib/api/bookmarks';
import { listMyCollections } from '$lib/api/collections';
import type { PageLoad } from './$types';
export const ssr = false;
/**
* Cheap pair-fetch for the landing overview's counts. Each call asks
* for `limit: 1` so the server still returns the paged envelope's
* `total` without dragging the full payload across the wire.
*/
export const load: PageLoad = async () => {
try {
const [bookmarks, collections] = await Promise.all([
listMyBookmarks({ limit: 1 }),
listMyCollections({ limit: 1 })
]);
return {
authenticated: true,
bookmark_count: bookmarks.page.total ?? 0,
collection_count: collections.page.total ?? 0
};
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { authenticated: false, bookmark_count: 0, collection_count: 0 };
}
throw e;
}
};

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { changePassword } from '$lib/api/auth';
import { ApiError } from '$lib/api/client';
import { session } from '$lib/session.svelte';
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let submitting = $state(false);
let success = $state<string | null>(null);
let error: string | null = $state(null);
const passwordsMatch = $derived(
newPassword.length > 0 && newPassword === confirmPassword
);
const canSubmit = $derived(
Boolean(session.user) &&
currentPassword.length > 0 &&
newPassword.length >= 8 &&
passwordsMatch &&
!submitting
);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
submitting = true;
success = null;
error = null;
try {
await changePassword({
current_password: currentPassword,
new_password: newPassword
});
success = 'Password updated. Other devices have been signed out.';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (e) {
if (e instanceof ApiError && e.status === 401 && !session.user) {
await goto('/login');
return;
}
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
{#if !session.user}
<p class="status" data-testid="account-signin">
<a href="/login?next=/profile/account">Sign in</a> to change your password.
</p>
{:else}
<section class="card">
<h2>Change password</h2>
<p class="hint">
Changing your password signs out every other device using this account.
Bot API tokens keep working — revoke them individually from the bot-token
list if you want to invalidate them too.
</p>
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
<label class="form-field">
<span>Current password</span>
<input
type="password"
bind:value={currentPassword}
autocomplete="current-password"
required
data-testid="current-password"
/>
</label>
<label class="form-field">
<span>New password</span>
<input
type="password"
bind:value={newPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="new-password"
/>
</label>
<label class="form-field">
<span>Confirm new password</span>
<input
type="password"
bind:value={confirmPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="confirm-password"
/>
{#if confirmPassword.length > 0 && !passwordsMatch}
<span class="field-error" role="alert" data-testid="mismatch">
Passwords don't match.
</span>
{/if}
</label>
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="password-submit"
>
{submitting ? 'Updating…' : 'Update password'}
</button>
{#if success}
<p class="success" data-testid="password-success">{success}</p>
{/if}
{#if error}
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
{/if}
</form>
</section>
{/if}
<style>
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
max-width: 32rem;
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.field-error {
color: var(--danger);
font-size: var(--font-sm);
}
.form-error {
color: var(--danger);
}
.success {
color: var(--success);
}
.status {
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import BookmarkList from '$lib/components/BookmarkList.svelte';
let { data } = $props();
const bookmarks = $derived(data.bookmarks);
</script>
{#if data.error}
<p class="error" role="alert" data-testid="profile-bookmarks-error">
Couldn't load bookmarks: {data.error}
</p>
{:else if !data.authenticated}
<p class="hint" data-testid="profile-bookmarks-signin">
<a href="/login?next=/profile/bookmarks">Sign in</a> to see your bookmarks.
</p>
{:else if bookmarks.length === 0}
<p class="hint" data-testid="profile-bookmarks-empty">No bookmarks yet.</p>
{:else}
<BookmarkList {bookmarks} testid="profile-bookmark-list" />
{/if}
<style>
.hint {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
</style>

View File

@@ -0,0 +1,25 @@
import { ApiError } from '$lib/api/client';
import { listMyBookmarks } from '$lib/api/bookmarks';
import type { PageLoad } from './$types';
export const ssr = false;
/**
* Mirrors the top-level `/bookmarks` route's three-state load shape so
* a backend hiccup (5xx, network) surfaces inline within the profile
* tab rather than bouncing the user out of the tabbed shell.
*/
export const load: PageLoad = async () => {
try {
const page = await listMyBookmarks();
return { bookmarks: page.items, authenticated: true, error: null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { bookmarks: [], authenticated: false, error: null };
}
if (e instanceof ApiError) {
return { bookmarks: [], authenticated: true, error: e.message };
}
throw e;
}
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
let { data } = $props();
const collections = $derived(data.collections);
</script>
{#if data.error}
<p class="error" role="alert" data-testid="profile-collections-error">
Couldn't load collections: {data.error}
</p>
{:else if !data.authenticated}
<p class="hint" data-testid="profile-collections-signin">
<a href="/login?next=/profile/collections">Sign in</a> to see your collections.
</p>
{:else if collections.length === 0}
<p class="hint" data-testid="profile-collections-empty">
You don't have any collections yet. Open any manga and use
<strong>Add to collection</strong> to start one.
</p>
{:else}
<CollectionsGrid {collections} />
{/if}
<style>
.hint {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
</style>

View File

@@ -0,0 +1,20 @@
import { ApiError } from '$lib/api/client';
import { listMyCollections } from '$lib/api/collections';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async () => {
try {
const page = await listMyCollections({ limit: 200 });
return { collections: page.items, authenticated: true, error: null };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { collections: [], authenticated: false, error: null };
}
if (e instanceof ApiError) {
return { collections: [], authenticated: true, error: e.message };
}
throw e;
}
};

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;
}
};

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import type { ReaderMode, ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme, type Theme } from '$lib/theme.svelte';
import Monitor from '@lucide/svelte/icons/monitor';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import FileText from '@lucide/svelte/icons/file-text';
import ScrollText from '@lucide/svelte/icons/scroll-text';
const READER_MODES: { value: ReaderMode; label: string }[] = [
{ value: 'single', label: 'Single page' },
{ value: 'continuous', label: 'Continuous (scroll)' }
];
const READER_GAPS: { value: ReaderPageGap; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' }
];
function setTheme(next: Theme) {
theme.set(next);
}
</script>
<section class="card" aria-label="Appearance">
<h2>Appearance</h2>
<fieldset class="picker">
<legend>Theme</legend>
<label class="option" class:selected={theme.value === 'system'}>
<input
type="radio"
name="theme"
value="system"
checked={theme.value === 'system'}
onchange={() => setTheme('system')}
data-testid="theme-radio-system"
/>
<Monitor size={18} aria-hidden="true" />
<span>System</span>
</label>
<label class="option" class:selected={theme.value === 'light'}>
<input
type="radio"
name="theme"
value="light"
checked={theme.value === 'light'}
onchange={() => setTheme('light')}
data-testid="theme-radio-light"
/>
<Sun size={18} aria-hidden="true" />
<span>Light</span>
</label>
<label class="option" class:selected={theme.value === 'dark'}>
<input
type="radio"
name="theme"
value="dark"
checked={theme.value === 'dark'}
onchange={() => setTheme('dark')}
data-testid="theme-radio-dark"
/>
<Moon size={18} aria-hidden="true" />
<span>Dark</span>
</label>
</fieldset>
</section>
<section class="card" aria-label="Reader">
<h2>Reader</h2>
{#if !session.user}
<p class="hint" data-testid="reader-prefs-guest-hint">
Sign in to sync these settings across devices. Until then they live in this browser.
</p>
{/if}
<fieldset class="picker">
<legend>Layout</legend>
{#each READER_MODES as opt (opt.value)}
<label class="option" class:selected={preferences.readerMode === opt.value}>
<input
type="radio"
name="reader-mode"
value={opt.value}
checked={preferences.readerMode === opt.value}
onchange={() => preferences.setMode(opt.value)}
data-testid={`reader-mode-radio-${opt.value}`}
/>
{#if opt.value === 'single'}
<FileText size={18} aria-hidden="true" />
{:else}
<ScrollText size={18} aria-hidden="true" />
{/if}
<span>{opt.label}</span>
</label>
{/each}
</fieldset>
{#if preferences.readerMode === 'continuous'}
<fieldset class="picker gap-picker">
<legend>Page gap</legend>
{#each READER_GAPS as opt (opt.value)}
<label class="option" class:selected={preferences.readerPageGap === opt.value}>
<input
type="radio"
name="reader-gap"
value={opt.value}
checked={preferences.readerPageGap === opt.value}
onchange={() => preferences.setGap(opt.value)}
data-testid={`reader-gap-radio-${opt.value}`}
/>
<span>{opt.label}</span>
</label>
{/each}
</fieldset>
{/if}
</section>
<style>
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
max-width: 32rem;
margin-bottom: var(--space-4);
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
.picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
border: none;
padding: 0;
margin: 0;
}
.gap-picker {
margin-top: var(--space-3);
}
.picker legend {
font-size: var(--font-sm);
color: var(--text);
padding: 0;
margin-bottom: var(--space-2);
}
.option {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
font-size: var(--font-sm);
cursor: pointer;
transition:
background var(--transition),
border-color var(--transition);
}
.option:hover {
background: var(--surface-elevated);
}
.option input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.option.selected {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--text);
}
.option:has(input:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>

View File

@@ -1,354 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { changePassword } from '$lib/api/auth';
import { ApiError } from '$lib/api/client';
import type { ReaderMode, ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme, type Theme } from '$lib/theme.svelte';
import Monitor from '@lucide/svelte/icons/monitor';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import FileText from '@lucide/svelte/icons/file-text';
import ScrollText from '@lucide/svelte/icons/scroll-text';
const READER_MODES: { value: ReaderMode; label: string }[] = [
{ value: 'single', label: 'Single page' },
{ value: 'continuous', label: 'Continuous (scroll)' }
];
const READER_GAPS: { value: ReaderPageGap; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' }
];
function setTheme(next: Theme) {
theme.set(next);
}
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let submitting = $state(false);
let success = $state<string | null>(null);
let error: string | null = $state(null);
const passwordsMatch = $derived(
newPassword.length > 0 && newPassword === confirmPassword
);
const canSubmit = $derived(
Boolean(session.user) &&
currentPassword.length > 0 &&
newPassword.length >= 8 &&
passwordsMatch &&
!submitting
);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
submitting = true;
success = null;
error = null;
try {
await changePassword({
current_password: currentPassword,
new_password: newPassword
});
success = 'Password updated. Other devices have been signed out.';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (e) {
if (e instanceof ApiError && e.status === 401 && !session.user) {
// The CurrentUser extractor rejected us — session must
// have been wiped externally. Bounce to login.
await goto('/login');
return;
}
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Settings — Mangalord</title>
</svelte:head>
<h1>Settings</h1>
<section class="card" aria-label="Appearance">
<h2>Appearance</h2>
<fieldset class="theme-picker">
<legend>Theme</legend>
<label class="theme-option" class:selected={theme.value === 'system'}>
<input
type="radio"
name="theme"
value="system"
checked={theme.value === 'system'}
onchange={() => setTheme('system')}
data-testid="theme-radio-system"
/>
<Monitor size={18} aria-hidden="true" />
<span>System</span>
</label>
<label class="theme-option" class:selected={theme.value === 'light'}>
<input
type="radio"
name="theme"
value="light"
checked={theme.value === 'light'}
onchange={() => setTheme('light')}
data-testid="theme-radio-light"
/>
<Sun size={18} aria-hidden="true" />
<span>Light</span>
</label>
<label class="theme-option" class:selected={theme.value === 'dark'}>
<input
type="radio"
name="theme"
value="dark"
checked={theme.value === 'dark'}
onchange={() => setTheme('dark')}
data-testid="theme-radio-dark"
/>
<Moon size={18} aria-hidden="true" />
<span>Dark</span>
</label>
</fieldset>
</section>
<section class="card" aria-label="Reader">
<h2>Reader</h2>
{#if !session.user}
<p class="hint" data-testid="reader-prefs-guest-hint">
Sign in to sync these settings across devices. Until then they live in this browser.
</p>
{/if}
<fieldset class="theme-picker">
<legend>Layout</legend>
{#each READER_MODES as opt (opt.value)}
<label class="theme-option" class:selected={preferences.readerMode === opt.value}>
<input
type="radio"
name="reader-mode"
value={opt.value}
checked={preferences.readerMode === opt.value}
onchange={() => preferences.setMode(opt.value)}
data-testid={`reader-mode-radio-${opt.value}`}
/>
{#if opt.value === 'single'}
<FileText size={18} aria-hidden="true" />
{:else}
<ScrollText size={18} aria-hidden="true" />
{/if}
<span>{opt.label}</span>
</label>
{/each}
</fieldset>
{#if preferences.readerMode === 'continuous'}
<fieldset class="theme-picker gap-picker">
<legend>Page gap</legend>
{#each READER_GAPS as opt (opt.value)}
<label class="theme-option" class:selected={preferences.readerPageGap === opt.value}>
<input
type="radio"
name="reader-gap"
value={opt.value}
checked={preferences.readerPageGap === opt.value}
onchange={() => preferences.setGap(opt.value)}
data-testid={`reader-gap-radio-${opt.value}`}
/>
<span>{opt.label}</span>
</label>
{/each}
</fieldset>
{/if}
</section>
{#if !session.loaded}
<p class="status" data-testid="settings-loading">Loading…</p>
{:else if !session.user}
<p class="status" data-testid="settings-signin">
<a href="/login">Sign in</a> to change your password.
</p>
{:else}
<section class="card">
<h2>Change password</h2>
<p class="hint">
Changing your password signs out every other device using this account.
Bot API tokens keep working — revoke them individually from the bot-token
list if you want to invalidate them too.
</p>
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
<label class="form-field">
<span>Current password</span>
<input
type="password"
bind:value={currentPassword}
autocomplete="current-password"
required
data-testid="current-password"
/>
</label>
<label class="form-field">
<span>New password</span>
<input
type="password"
bind:value={newPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="new-password"
/>
</label>
<label class="form-field">
<span>Confirm new password</span>
<input
type="password"
bind:value={confirmPassword}
autocomplete="new-password"
minlength="8"
required
data-testid="confirm-password"
/>
{#if confirmPassword.length > 0 && !passwordsMatch}
<span class="field-error" role="alert" data-testid="mismatch">
Passwords don't match.
</span>
{/if}
</label>
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="password-submit"
>
{submitting ? 'Updating…' : 'Update password'}
</button>
{#if success}
<p class="success" data-testid="password-success">{success}</p>
{/if}
{#if error}
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
{/if}
</form>
</section>
{/if}
<style>
.status {
color: var(--text-muted);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
max-width: 32rem;
margin-bottom: var(--space-4);
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.field-error {
color: var(--danger);
font-size: var(--font-sm);
}
.form-error {
color: var(--danger);
}
.success {
color: var(--success);
}
.theme-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
border: none;
padding: 0;
margin: 0;
}
.gap-picker {
margin-top: var(--space-3);
}
.theme-picker legend {
font-size: var(--font-sm);
color: var(--text);
padding: 0;
margin-bottom: var(--space-2);
}
.theme-option {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
font-size: var(--font-sm);
cursor: pointer;
transition:
background var(--transition),
border-color var(--transition);
}
.theme-option:hover {
background: var(--surface-elevated);
}
.theme-option input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
.theme-option.selected {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--text);
}
.theme-option:has(input:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
/**
* /settings was absorbed into /profile in 0.18.0 — the appearance and
* reader prefs moved to /profile/preferences and the password form to
* /profile/account. Anything else that lands here is a stale bookmark;
* bounce them to the new home.
*/
export const ssr = false;
export const load: PageLoad = () => {
redirect(308, '/profile/preferences');
};

View File

@@ -1,24 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import { createManga, type MangaStatus } from '$lib/api/mangas';
import { request } from '$lib/api/client';
import { createChapter } from '$lib/api/chapters';
import { session } from '$lib/session.svelte';
import { formatBytes, validateImageFile } from '$lib/upload-validation';
import Chip from '$lib/components/Chip.svelte';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import Trash2 from '@lucide/svelte/icons/trash-2';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import BookImage from '@lucide/svelte/icons/book-image';
import ChapterPagesEditor, {
type PendingPage
} from '$lib/components/ChapterPagesEditor.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
let { data } = $props();
const mangas = $derived(data.mangas);
const genres = $derived(data.genres);
// -------- Manga form state --------
// ---- Manga form state ----
let mangaTitle = $state('');
let mangaStatus = $state<MangaStatus>('ongoing');
let mangaDescription = $state('');
@@ -29,13 +26,42 @@
let mangaGenreIds = $state<string[]>([]);
let coverFile = $state<File | null>(null);
let coverError = $state<string | null>(null);
let mangaSubmitting = $state(false);
let mangaError = $state<string | null>(null);
let mangaFieldErrors = $state<Record<string, string>>({});
let mangaSuccess = $state<string | null>(null);
const canSubmitManga = $derived(
mangaTitle.trim().length > 0 && !coverError && !mangaSubmitting
// ---- Initial-chapter staging ----
type StagedChapter = {
id: string;
number: number;
title: string;
pages: PendingPage[];
status: 'pending' | 'uploading' | 'done' | 'failed';
error: string | null;
};
let stagedChapters = $state<StagedChapter[]>([]);
let submitting = $state(false);
let mangaError = $state<string | null>(null);
let success = $state<string | null>(null);
const allChapterPagesValid = $derived(
stagedChapters.every((c) => c.pages.every((p) => !p.error))
);
const allChapterNumbersUnique = $derived(
new Set(stagedChapters.map((c) => c.number)).size === stagedChapters.length
);
const allChapterNumbersValid = $derived(
stagedChapters.every((c) => Number.isInteger(c.number) && c.number >= 1)
);
const allChaptersHavePages = $derived(
stagedChapters.every((c) => c.pages.length > 0)
);
const canSubmit = $derived(
mangaTitle.trim().length > 0 &&
!coverError &&
!submitting &&
allChapterPagesValid &&
allChapterNumbersUnique &&
allChapterNumbersValid &&
allChaptersHavePages
);
function addAuthor() {
@@ -46,11 +72,9 @@
}
authorDraft = '';
}
function removeAuthor(name: string) {
mangaAuthors = mangaAuthors.filter((a) => a !== name);
}
function addAltTitle() {
const t = altTitleDraft.trim();
if (!t) return;
@@ -59,17 +83,14 @@
}
altTitleDraft = '';
}
function removeAltTitle(t: string) {
mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
}
function toggleGenre(id: string) {
mangaGenreIds = mangaGenreIds.includes(id)
? mangaGenreIds.filter((g) => g !== id)
: [...mangaGenreIds, id];
}
function onCoverChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
@@ -77,19 +98,40 @@
coverError = file ? validateImageFile(file) : null;
}
async function submitManga(e: SubmitEvent) {
function addChapter() {
// Auto-default to the next number after the highest pending
// one (or 1 if this is the first).
const next =
stagedChapters.reduce((max, c) => Math.max(max, c.number), 0) + 1;
stagedChapters = [
...stagedChapters,
{
id: crypto.randomUUID(),
number: next,
title: '',
pages: [],
status: 'pending',
error: null
}
];
}
function removeChapter(id: string) {
// Releasing the staged-chapter row drops the ChapterPagesEditor,
// which revokes its own object URLs on destroy — no leak.
stagedChapters = stagedChapters.filter((c) => c.id !== id);
}
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmitManga) return;
// Pick up an unsubmitted token if the user hit Submit without
// pressing Add — otherwise the typed name silently disappears.
if (!canSubmit) return;
if (authorDraft.trim()) addAuthor();
if (altTitleDraft.trim()) addAltTitle();
mangaSubmitting = true;
submitting = true;
mangaError = null;
mangaFieldErrors = {};
mangaSuccess = null;
success = null;
let manga;
try {
const manga = await createManga(
manga = await createManga(
{
title: mangaTitle.trim(),
status: mangaStatus,
@@ -100,141 +142,45 @@
},
coverFile ?? undefined
);
mangaSuccess = `Created "${manga.title}".`;
mangaTitle = '';
mangaStatus = 'ongoing';
mangaAuthors = [];
mangaAltTitles = [];
mangaGenreIds = [];
mangaDescription = '';
coverFile = null;
} catch (e) {
applyApiError(e, (msg) => (mangaError = msg), (fields) => (mangaFieldErrors = fields));
} finally {
mangaSubmitting = false;
}
}
// -------- Chapter form state --------
type PendingPage = { id: string; file: File; error: string | null };
let chapterMangaId = $state<string>('');
let chapterNumber = $state<number | null>(null);
let chapterTitle = $state('');
let chapterPages = $state<PendingPage[]>([]);
let chapterSubmitting = $state(false);
let chapterError = $state<string | null>(null);
let chapterFieldErrors = $state<Record<string, string>>({});
let chapterSuccess = $state<string | null>(null);
let isDragOver = $state(false);
const selectedManga = $derived(mangas.find((m) => m.id === chapterMangaId) ?? null);
const selectedMangaAuthors = $derived(
selectedManga ? selectedManga.authors.map((a) => a.name).join(', ') : ''
);
const allChapterPagesValid = $derived(chapterPages.every((p) => !p.error));
const canSubmitChapter = $derived(
Boolean(chapterMangaId) &&
chapterNumber != null &&
chapterNumber > 0 &&
chapterPages.length > 0 &&
allChapterPagesValid &&
!chapterSubmitting
);
function addPageFiles(files: File[] | FileList) {
const arr = Array.from(files);
const additions: PendingPage[] = arr.map((file) => ({
id: crypto.randomUUID(),
file,
error: validateImageFile(file)
}));
chapterPages = [...chapterPages, ...additions];
}
function removePage(id: string) {
chapterPages = chapterPages.filter((p) => p.id !== id);
}
function movePage(id: string, dir: -1 | 1) {
const i = chapterPages.findIndex((p) => p.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= chapterPages.length) return;
const copy = chapterPages.slice();
[copy[i], copy[j]] = [copy[j], copy[i]];
chapterPages = copy;
}
function onPagesInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) addPageFiles(input.files);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
if (e.dataTransfer?.files) addPageFiles(e.dataTransfer.files);
}
function onDragOver(e: DragEvent) {
e.preventDefault();
isDragOver = true;
}
function onDragLeave() {
isDragOver = false;
}
async function submitChapter(e: SubmitEvent) {
e.preventDefault();
if (!canSubmitChapter || chapterNumber == null) return;
chapterSubmitting = true;
chapterError = null;
chapterFieldErrors = {};
chapterSuccess = null;
try {
const form = new FormData();
const metadata: Record<string, unknown> = { number: chapterNumber };
if (chapterTitle.trim()) metadata.title = chapterTitle.trim();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
for (const p of chapterPages) form.append('page', p.file);
await request<unknown>(
`/v1/mangas/${encodeURIComponent(chapterMangaId)}/chapters`,
{ method: 'POST', body: form }
);
chapterSuccess = `Uploaded chapter ${chapterNumber} (${chapterPages.length} pages).`;
chapterNumber = null;
chapterTitle = '';
chapterPages = [];
} catch (e) {
applyApiError(
e,
(msg) => (chapterError = msg),
(fields) => (chapterFieldErrors = fields)
);
} finally {
chapterSubmitting = false;
}
}
function applyApiError(
e: unknown,
setMessage: (m: string) => void,
setFields: (f: Record<string, string>) => void
) {
if (e instanceof ApiError && e.status === 401) {
goto('/login');
if (e instanceof ApiError && e.status === 401) {
await goto('/login?next=/upload');
return;
}
mangaError = e instanceof Error ? e.message : String(e);
submitting = false;
return;
}
const message = e instanceof Error ? e.message : String(e);
setMessage(message);
setFields({});
// Manga is created; ship chapters one at a time and surface
// per-row status. Failures don't roll back the manga — the
// user can retry just the failed chapters from the manga
// page's Upload-chapter button.
for (const c of stagedChapters) {
c.status = 'uploading';
c.error = null;
try {
await createChapter(
manga.id,
{ number: c.number, title: c.title.trim() || null },
c.pages.map((p) => p.file)
);
c.status = 'done';
} catch (e) {
c.status = 'failed';
c.error = e instanceof Error ? e.message : String(e);
}
}
const failed = stagedChapters.filter((c) => c.status === 'failed');
if (failed.length === 0) {
// All-good — land the user on the manga page where they
// can confirm and continue uploading.
await goto(`/manga/${manga.id}`);
return;
}
success = `"${manga.title}" was created, but ${failed.length} of ${stagedChapters.length} chapters failed. Fix them and retry from the manga page.`;
submitting = false;
}
</script>
@@ -242,18 +188,18 @@
<title>Upload — Mangalord</title>
</svelte:head>
<h1>Upload</h1>
<h1>Create manga</h1>
{#if !session.loaded}
<p class="status" data-testid="upload-loading">Loading…</p>
{:else if !session.user}
<p class="status" data-testid="upload-signin">
<a href="/login">Sign in</a> to upload mangas or chapters.
<a href="/login?next=/upload">Sign in</a> to upload a manga.
</p>
{:else}
<section class="card">
<h2>Create manga</h2>
<form onsubmit={submitManga} action="javascript:void(0)" data-testid="manga-form">
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
<section class="card">
<h2>Manga details</h2>
<label class="form-field">
<span>Title <span aria-hidden="true">*</span></span>
<input
@@ -263,9 +209,6 @@
maxlength="200"
data-testid="manga-title"
/>
{#if mangaFieldErrors.title}
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
{/if}
</label>
<label class="form-field">
@@ -383,179 +326,112 @@
<span class="field-error" role="alert">{coverError}</span>
{/if}
</label>
<button class="primary" type="submit" disabled={!canSubmitManga} data-testid="manga-submit">
{mangaSubmitting ? 'Creating…' : 'Create manga'}
</button>
{#if mangaSuccess}
<p class="success" data-testid="manga-success">{mangaSuccess}</p>
{/if}
{#if mangaError}
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
{/if}
</form>
</section>
</section>
<section class="card">
<h2>Upload chapter</h2>
{#if mangas.length === 0}
<p class="status" data-testid="chapter-no-mangas">
No mangas yet — create one above first.
</p>
{:else}
<form
onsubmit={submitChapter}
action="javascript:void(0)"
data-testid="chapter-form"
>
<label class="form-field">
<span>Manga <span aria-hidden="true">*</span></span>
<select
bind:value={chapterMangaId}
required
data-testid="chapter-manga"
>
<option value="">Choose…</option>
{#each mangas as m (m.id)}
<option value={m.id}>
{m.title}{#if m.authors.length > 0}{m.authors
.map((a) => a.name)
.join(', ')}{/if}
</option>
{/each}
</select>
</label>
{#if selectedManga}
<div class="manga-preview" data-testid="chapter-manga-preview">
{#if selectedManga.cover_image_path}
<img
src={fileUrl(selectedManga.cover_image_path)}
alt=""
class="preview-cover"
loading="lazy"
/>
{:else}
<span class="preview-cover preview-cover-placeholder" aria-hidden="true">
<BookImage size={22} aria-hidden="true" />
</span>
{/if}
<div class="preview-meta">
<span class="preview-title">{selectedManga.title}</span>
{#if selectedMangaAuthors}
<span class="preview-author">{selectedMangaAuthors}</span>
{/if}
</div>
</div>
{/if}
<label class="form-field">
<span>Chapter number <span aria-hidden="true">*</span></span>
<input
type="number"
min="1"
step="1"
bind:value={chapterNumber}
required
data-testid="chapter-number"
/>
</label>
<label class="form-field">
<span>Title (optional)</span>
<input
type="text"
bind:value={chapterTitle}
maxlength="200"
data-testid="chapter-title"
/>
</label>
<div
class="drop-zone"
class:drag-over={isDragOver}
ondrop={onDrop}
ondragover={onDragOver}
ondragleave={onDragLeave}
role="region"
aria-label="page upload"
data-testid="drop-zone"
<section class="card">
<div class="chapters-header">
<h2>Initial chapters (optional)</h2>
<button
type="button"
class="add-chapter"
onclick={addChapter}
data-testid="add-chapter"
>
<UploadCloud size={32} aria-hidden="true" class="drop-icon" />
<p>
Drop pages here, or
<label class="file-link">
browse
<input
type="file"
accept="image/*"
multiple
onchange={onPagesInputChange}
data-testid="chapter-pages-input"
/>
</label>
</p>
</div>
{#if chapterPages.length > 0}
<ol class="pages" data-testid="chapter-pages-list">
{#each chapterPages as p, i (p.id)}
<li class:invalid={p.error}>
<span class="page-name">{p.file.name}</span>
<span class="page-size">{formatBytes(p.file.size)}</span>
<Plus size={14} aria-hidden="true" />
<span>Add chapter</span>
</button>
</div>
{#if stagedChapters.length === 0}
<p class="hint" data-testid="no-chapters-hint">
You can skip this — chapters can also be added later from the
manga's page.
</p>
{:else}
<ul class="chapter-list" data-testid="staged-chapter-list">
{#each stagedChapters as c, idx (c.id)}
<li class="staged-chapter" data-testid="staged-chapter">
<div class="staged-header">
<span class="staged-index">#{idx + 1}</span>
<label class="staged-field number-field">
<span class="visually-hidden">Chapter number</span>
<input
type="number"
min="1"
step="1"
bind:value={c.number}
required
data-testid="staged-chapter-number"
/>
</label>
<label class="staged-field title-field">
<span class="visually-hidden">Chapter title</span>
<input
type="text"
placeholder="Chapter title (optional)"
bind:value={c.title}
maxlength="200"
data-testid="staged-chapter-title"
/>
</label>
<span class="staged-status status-{c.status}">
{#if c.status === 'uploading'}
Uploading…
{:else if c.status === 'done'}
✓ Uploaded
{:else if c.status === 'failed'}
Failed
{/if}
</span>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, -1)}
disabled={i === 0}
aria-label="Move up"
title="Move up"
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, 1)}
disabled={i === chapterPages.length - 1}
aria-label="Move down"
title="Move down"
>
<ArrowDown size={16} aria-hidden="true" />
</button>
<button
class="icon-btn danger"
type="button"
onclick={() => removePage(p.id)}
aria-label="Remove page"
title="Remove page"
data-testid="page-remove"
onclick={() => removeChapter(c.id)}
aria-label="Remove chapter"
title="Remove chapter"
data-testid="staged-chapter-remove"
>
<Trash2 size={16} aria-hidden="true" />
</button>
{#if p.error}
<span class="field-error" role="alert">{p.error}</span>
{/if}
</li>
{/each}
</ol>
{/if}
<button
class="primary"
type="submit"
disabled={!canSubmitChapter}
data-testid="chapter-submit"
>
{chapterSubmitting ? 'Uploading…' : 'Upload chapter'}
</button>
{#if chapterSuccess}
<p class="success" data-testid="chapter-success">{chapterSuccess}</p>
{/if}
{#if chapterError}
<p role="alert" class="form-error" data-testid="chapter-error">
{chapterError}
</div>
{#if c.error}
<p class="field-error" role="alert">{c.error}</p>
{/if}
<ChapterPagesEditor
bind:pages={c.pages}
testidPrefix="staged-chapter-pages"
/>
</li>
{/each}
</ul>
{#if !allChapterNumbersUnique}
<p class="field-error" role="alert">
Two staged chapters share the same number — each must
be unique.
</p>
{/if}
</form>
{#if !allChaptersHavePages && stagedChapters.length > 0}
<p class="field-error" role="alert">
Each staged chapter needs at least one page.
</p>
{/if}
{/if}
</section>
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="manga-submit"
>
{submitting ? 'Submitting…' : 'Create manga'}
</button>
{#if success}
<p class="success" data-testid="manga-success">{success}</p>
{/if}
</section>
{#if mangaError}
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
{/if}
</form>
{/if}
<style>
@@ -563,15 +439,17 @@
color: var(--text-muted);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
@@ -581,7 +459,6 @@
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start;
}
@@ -640,114 +517,6 @@
cursor: pointer;
}
.manga-preview {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-elevated);
}
.preview-cover {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
flex-shrink: 0;
}
.preview-cover-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.preview-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.preview-title {
font-weight: var(--weight-semibold);
color: var(--text);
}
.preview-author {
color: var(--text-muted);
font-size: var(--font-sm);
}
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
border: 2px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: var(--space-6);
text-align: center;
background: var(--surface);
color: var(--text-muted);
transition:
background var(--transition),
border-color var(--transition);
}
.drop-zone :global(.drop-icon) {
color: var(--text-muted);
}
.drop-zone.drag-over {
background: var(--primary-soft-bg);
border-color: var(--primary);
}
.file-link input[type='file'] {
display: none;
}
.file-link {
color: var(--primary);
text-decoration: underline;
cursor: pointer;
}
.pages {
padding: 0;
margin: 0;
list-style: decimal inside;
}
.pages li {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border);
}
.pages li.invalid {
background: var(--danger-soft-bg);
}
.page-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-size {
color: var(--text-muted);
font-size: var(--font-sm);
}
.icon-btn {
display: inline-flex;
align-items: center;
@@ -780,4 +549,97 @@
.icon-btn.danger:hover:not(:disabled) {
color: var(--danger);
}
.chapters-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.chapters-header h2 {
margin: 0;
}
.add-chapter {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 32px;
border-radius: var(--radius-sm);
font-size: var(--font-sm);
cursor: pointer;
}
.add-chapter:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.chapter-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.staged-chapter {
background: var(--surface-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.staged-header {
display: grid;
grid-template-columns: auto 80px 1fr auto auto;
gap: var(--space-2);
align-items: center;
}
.staged-index {
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
white-space: nowrap;
}
.staged-field {
display: contents;
}
.staged-status {
font-size: var(--font-sm);
color: var(--text-muted);
white-space: nowrap;
}
.staged-status.status-done {
color: var(--success);
}
.staged-status.status-failed {
color: var(--danger);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View File

@@ -1,17 +1,13 @@
import { listMangas, type MangaCard } from '$lib/api/mangas';
import { listGenres, type Genre } from '$lib/api/genres';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async () => {
// The chapter form needs a list of mangas to attach the new chapter
// to. There's no ownership concept yet, so any authenticated user can
// see and add to any manga. Genres are needed for the create-manga
// form's picker.
const [{ items }, genres] = await Promise.all([
listMangas({ limit: 200, sort: 'title' }),
listGenres()
]);
return { mangas: items as MangaCard[], genres: genres as Genre[] };
// /upload is now for new-manga creation only — additional
// chapters land on /manga/[id]/upload-chapter via a button on the
// manga page. The only async dep here is the curated genre list
// for the picker.
const genres = await listGenres();
return { genres: genres as Genre[] };
};