From 6c901e64c914013fd356fce399529ca7450814a0 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 13 Jun 2026 15:51:38 +0200 Subject: [PATCH] feat(search): tag-based page search surface + per-page tags & collections Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 + backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- .../0023_page_collections_and_tags.sql | 48 ++ backend/src/api/collections.rs | 69 +- backend/src/api/mod.rs | 2 + backend/src/api/page_tags.rs | 443 ++++++++++ backend/src/domain/collection.rs | 16 + backend/src/domain/mod.rs | 7 +- backend/src/domain/page_tag.rs | 63 ++ backend/src/error.rs | 26 + backend/src/repo/collection.rs | 131 ++- backend/src/repo/mod.rs | 1 + backend/src/repo/page_tag.rs | 374 ++++++++ backend/tests/api_collection_pages.rs | 331 ++++++++ backend/tests/api_page_tags.rs | 646 ++++++++++++++ frontend/e2e/back-nav-flow.spec.ts | 60 +- frontend/e2e/page-context-menu.spec.ts | 330 ++++++++ frontend/e2e/reader-page-deep-link.spec.ts | 250 ++++++ frontend/e2e/search.spec.ts | 251 ++++++ frontend/package.json | 2 +- frontend/src/lib/api/page_collections.test.ts | 101 +++ frontend/src/lib/api/page_collections.ts | 68 ++ frontend/src/lib/api/page_tags.test.ts | 166 ++++ frontend/src/lib/api/page_tags.ts | 145 ++++ .../src/lib/components/AddTagsSheet.svelte | 315 +++++++ .../components/AddTagsSheet.svelte.test.ts | 113 +++ .../components/AddToCollectionModal.svelte | 97 ++- .../AddToCollectionModal.svelte.test.ts | 100 +++ .../src/lib/components/PageContextMenu.svelte | 231 +++++ .../components/PageContextMenu.svelte.test.ts | 109 +++ .../src/lib/components/PageTagsList.svelte | 144 ++++ .../components/PageTagsList.svelte.test.ts | 85 ++ .../lib/components/TaggedChapterRow.svelte | 123 +++ .../TaggedChapterRow.svelte.test.ts | 67 ++ .../src/lib/components/TaggedMangaRow.svelte | 127 +++ .../components/TaggedMangaRow.svelte.test.ts | 72 ++ .../src/lib/components/TaggedPageRow.svelte | 108 +++ .../components/TaggedPageRow.svelte.test.ts | 58 ++ frontend/src/lib/components/TapZone.svelte | 123 ++- .../src/lib/components/TapZone.svelte.test.ts | 186 +++- frontend/src/routes/+page.svelte | 45 +- .../src/routes/collections/[id]/+page.svelte | 162 +++- frontend/src/routes/collections/[id]/+page.ts | 15 +- frontend/src/routes/library/+page.svelte | 14 +- frontend/src/routes/library/+page.ts | 31 +- frontend/src/routes/manga/[id]/+page.svelte | 2 +- .../[id]/chapter/[chapter_id]/+page.svelte | 797 ++++++++++++++++-- frontend/src/routes/search/+page.svelte | 351 ++++++++ frontend/src/routes/search/+page.ts | 92 ++ 50 files changed, 6971 insertions(+), 132 deletions(-) create mode 100644 backend/migrations/0023_page_collections_and_tags.sql create mode 100644 backend/src/api/page_tags.rs create mode 100644 backend/src/domain/page_tag.rs create mode 100644 backend/src/repo/page_tag.rs create mode 100644 backend/tests/api_collection_pages.rs create mode 100644 backend/tests/api_page_tags.rs create mode 100644 frontend/e2e/page-context-menu.spec.ts create mode 100644 frontend/e2e/reader-page-deep-link.spec.ts create mode 100644 frontend/e2e/search.spec.ts create mode 100644 frontend/src/lib/api/page_collections.test.ts create mode 100644 frontend/src/lib/api/page_collections.ts create mode 100644 frontend/src/lib/api/page_tags.test.ts create mode 100644 frontend/src/lib/api/page_tags.ts create mode 100644 frontend/src/lib/components/AddTagsSheet.svelte create mode 100644 frontend/src/lib/components/AddTagsSheet.svelte.test.ts create mode 100644 frontend/src/lib/components/AddToCollectionModal.svelte.test.ts create mode 100644 frontend/src/lib/components/PageContextMenu.svelte create mode 100644 frontend/src/lib/components/PageContextMenu.svelte.test.ts create mode 100644 frontend/src/lib/components/PageTagsList.svelte create mode 100644 frontend/src/lib/components/PageTagsList.svelte.test.ts create mode 100644 frontend/src/lib/components/TaggedChapterRow.svelte create mode 100644 frontend/src/lib/components/TaggedChapterRow.svelte.test.ts create mode 100644 frontend/src/lib/components/TaggedMangaRow.svelte create mode 100644 frontend/src/lib/components/TaggedMangaRow.svelte.test.ts create mode 100644 frontend/src/lib/components/TaggedPageRow.svelte create mode 100644 frontend/src/lib/components/TaggedPageRow.svelte.test.ts create mode 100644 frontend/src/routes/search/+page.svelte create mode 100644 frontend/src/routes/search/+page.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2954f15..613a913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,8 @@ docker compose -f docker-compose.dev.yml up -d These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure. - **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change. +- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user personal page tags live in `page_tags` (distinct from the shared manga `tags` taxonomy; free-form text, normalized at the API layer). Both tables cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design. +- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints is **reserved** for the planned OCR text-search input. Both aggregation handlers accept `text=` on the wire but reject non-empty values with 501 `text_search_not_yet_supported` so adding OCR later doesn't break the API shape. Adding OCR is then: a background worker writes `page_ocr_text` rows, a JOIN on the existing aggregation queries adds the new filter, the `text=` param starts validating instead of rejecting. - **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change. - **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously. - **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change. diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 4196357..64d3a80 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.60.2" +version = "0.62.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index de7a7d8..b391c11 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.60.2" +version = "0.62.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0023_page_collections_and_tags.sql b/backend/migrations/0023_page_collections_and_tags.sql new file mode 100644 index 0000000..845a3b3 --- /dev/null +++ b/backend/migrations/0023_page_collections_and_tags.sql @@ -0,0 +1,48 @@ +-- Per-page collections and tags. Collections become heterogeneous: the +-- existing `collection_mangas` join holds whole mangas, this new +-- `collection_pages` join holds individual pages. A page tagged or +-- collected references the stable `pages.id` UUID, so re-uploading a +-- chapter (which deletes and recreates the page rows) silently drops +-- any saves attached to it — cascade is intentional, matching the +-- semantics of the cover-image references in existing collections. + +CREATE TABLE collection_pages ( + collection_id uuid NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + added_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (collection_id, page_id) +); + +-- Reverse lookup: "which of my collections contain this page?" — the +-- reader's context menu pre-checks the matching collection rows on +-- open. +CREATE INDEX collection_pages_page_idx ON collection_pages (page_id); + +-- Per-user, per-page free-form tags. Distinct from the manga-level +-- shared `tags` taxonomy — these are personal annotations. Length cap +-- 64 leaves room for `namespace:value` conventions (e.g. +-- `character:askeladd`) without making the schema enforce them. +CREATE TABLE page_tags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + tag text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT page_tags_tag_nonempty CHECK (length(tag) > 0 AND length(tag) <= 64) +); + +-- One row per (user, page, tag). The repo upserts via ON CONFLICT DO +-- NOTHING so re-tagging is a no-op (handler returns 200 instead of 201). +CREATE UNIQUE INDEX page_tags_user_page_tag_uniq + ON page_tags (user_id, page_id, tag); + +-- "Show me everything I've tagged" — newest-first. Drives the library +-- Page-tags tab. +CREATE INDEX page_tags_user_idx ON page_tags (user_id, created_at DESC); + +-- "Show me what I've tagged X" — same tab, when a chip filter is +-- active. +CREATE INDEX page_tags_user_tag_idx ON page_tags (user_id, tag, created_at DESC); + +-- Per-page lookup for the context menu's contextual line. +CREATE INDEX page_tags_page_idx ON page_tags (page_id); diff --git a/backend/src/api/collections.rs b/backend/src/api/collections.rs index 3f259b7..c37622c 100644 --- a/backend/src/api/collections.rs +++ b/backend/src/api/collections.rs @@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::CurrentUser; use crate::domain::collection::{ - Collection, CollectionPatch, CollectionSummary, NewCollection, + Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection, }; use crate::domain::manga::Manga; use crate::domain::patch::Patch; @@ -27,10 +27,16 @@ pub fn routes() -> Router { "/collections/:id/mangas/:manga_id", delete(remove_manga), ) + .route("/collections/:id/pages", get(list_pages).post(add_page)) + .route("/collections/:id/pages/:page_id", delete(remove_page)) .route( "/mangas/:id/my-collections", get(list_my_collections_containing), ) + .route( + "/pages/:id/my-collections", + get(list_my_collections_containing_page), + ) } const MAX_NAME_LEN: usize = 64; @@ -54,11 +60,21 @@ pub struct AddMangaBody { pub manga_id: Uuid, } +#[derive(Debug, Deserialize)] +pub struct AddPageBody { + pub page_id: Uuid, +} + #[derive(Debug, Serialize)] pub struct MangaCollectionIds { pub collection_ids: Vec, } +#[derive(Debug, Serialize)] +pub struct PageCollectionIds { + pub collection_ids: Vec, +} + fn validate_name(name: &str) -> AppResult<()> { let trimmed = name.trim(); if trimmed.is_empty() { @@ -218,6 +234,57 @@ async fn list_my_collections_containing( Ok(Json(MangaCollectionIds { collection_ids: ids })) } +async fn list_pages( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Query(params): Query, +) -> AppResult>> { + 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_pages(&state.db, id, limit, offset).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn add_page( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(body): Json, +) -> AppResult { + require_owner_id(&state, user.id, id).await?; + // FK violation in `repo::collection::add_page` maps to NotFound, so + // no separate `repo::page::exists` check is needed — the insert is + // the existence check. + let created = repo::collection::add_page(&state.db, id, body.page_id).await?; + Ok(if created { StatusCode::CREATED } else { StatusCode::OK }) +} + +async fn remove_page( + State(state): State, + CurrentUser(user): CurrentUser, + Path((collection_id, page_id)): Path<(Uuid, Uuid)>, +) -> AppResult { + require_owner_id(&state, user.id, collection_id).await?; + repo::collection::remove_page(&state.db, collection_id, page_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_my_collections_containing_page( + State(state): State, + CurrentUser(user): CurrentUser, + Path(page_id): Path, +) -> AppResult> { + // Mirrors `list_my_collections_containing` for pages: unknown page + // returns an empty list, not 404 — keeps the endpoint side-effect- + // free and skips a distinguishing-status oracle. + let ids = repo::collection::list_collections_containing_page(&state.db, user.id, page_id) + .await?; + Ok(Json(PageCollectionIds { 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 diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index d8a29f3..0f8a6a3 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod genres; pub mod health; pub mod history; pub mod mangas; +pub mod page_tags; pub mod pagination; pub mod tags; @@ -28,6 +29,7 @@ pub fn routes() -> Router { .merge(tags::routes()) .merge(authors::routes()) .merge(collections::routes()) + .merge(page_tags::routes()) .merge(history::routes()) .merge(admin::routes()) } diff --git a/backend/src/api/page_tags.rs b/backend/src/api/page_tags.rs new file mode 100644 index 0000000..7607d56 --- /dev/null +++ b/backend/src/api/page_tags.rs @@ -0,0 +1,443 @@ +//! Per-page tag endpoints. See `migration 0023` for the underlying +//! schema and `repo::page_tag` for the query layer. All endpoints +//! require `CurrentUser` — every byte of this data is owned by the +//! caller. + +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::page_tag::{ + NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate, + TaggedPageItem, +}; +use crate::error::{AppError, AppResult}; +use crate::repo; +use crate::repo::page_tag::Order; + +pub fn routes() -> Router { + Router::new() + .route("/pages/:id/tags", post(add)) + .route("/pages/:id/tags/:tag", delete(remove)) + // GET uses `/my-tags` to mirror the `mangas/:id/my-collections` + // convention — the URL says whose tags we're reading even + // though the cookie already implies it. + .route("/pages/:id/my-tags", get(list_for_page)) + .route("/me/page-tags", get(list_mine)) + .route("/me/page-tags/distinct", get(list_distinct_mine)) + .route("/me/page-tags/chapters", get(list_chapters_for_tag)) + .route("/me/page-tags/mangas", get(list_mangas_for_tag)) +} + +const MAX_TAG_LEN: usize = 64; +/// Hard wire-level byte cap, applied before any allocation in +/// `normalize_tag`. A 10MB tag in the JSON body would otherwise +/// allocate twice in `to_lowercase()` + `split_whitespace().collect()` +/// before the 64-char post-normalize cap rejected it. 4 KiB is a +/// generous ~64x the post-normalize char cap and well below anything +/// a user could fairly call a "tag". +const MAX_TAG_BYTES: usize = 4096; +const DEFAULT_LIMIT: i64 = 50; +const DEFAULT_DISTINCT_LIMIT: i64 = 200; + +#[derive(Debug, Deserialize)] +pub struct ListMineParams { + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, + /// Restrict to this exact tag (chip filter in the library tab). + #[serde(default)] + pub tag: Option, + /// Prefix filter (autocomplete-style). + #[serde(default)] + pub q: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DistinctParams { + #[serde(default)] + pub q: Option, + #[serde(default = "default_distinct_limit")] + pub limit: i64, +} + +#[derive(Debug, Serialize)] +struct TagsResponse { + tags: Vec, +} + +fn default_limit() -> i64 { + DEFAULT_LIMIT +} +fn default_distinct_limit() -> i64 { + DEFAULT_DISTINCT_LIMIT +} + +/// Normalize a user-supplied tag for storage. Lowercases, trims, +/// collapses internal whitespace, rejects control chars and edge +/// colons, caps at 64 chars. Single source of truth so the same input +/// produces the same row regardless of which client sent it. +/// True for Unicode format / invisible chars that would let two +/// visually-identical tags coexist as distinct rows — `"funny"` and +/// `"funny\u{200d}"` are different codepoints but render the same, and +/// the autocomplete + chip cloud would split them. We hardcode the +/// well-known offenders (a subset of the Unicode `Cf` general category +/// plus the bidi-override block) so the file stays free of new deps. +fn is_invisible_format(c: char) -> bool { + matches!(c, + // Soft hyphen, Arabic letter mark, Mongolian vowel separator. + '\u{00AD}' | '\u{061C}' | '\u{180E}' + // ZWSP, ZWNJ, ZWJ, LRM, RLM. + | '\u{200B}'..='\u{200F}' + // LRE / RLE / PDF / LRO / RLO. + | '\u{202A}'..='\u{202E}' + // Word joiner, function-application markers, deprecated + // formatting (U+206A..U+206F). + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{206F}' + // BOM / ZWNBSP. + | '\u{FEFF}' + // Plane-14 LANGUAGE TAG + TAG characters. These can tunnel + // ASCII semantics invisibly (the same mechanism used in 2024's + // LLM prompt-injection smuggling work). Storing them would + // let two visually-identical tags coexist while carrying + // distinct hidden payloads. + | '\u{E0001}' + | '\u{E0020}'..='\u{E007F}' + ) +} + +fn normalize_tag(input: &str) -> AppResult { + // Fast-fail on absurd input before allocating in lowercase / + // whitespace passes. Cheap and bounds worst-case work. + if input.len() > MAX_TAG_BYTES { + return Err(AppError::ValidationFailed { + message: "tag too long".into(), + details: json!({ "tag": format!("max {MAX_TAG_BYTES} bytes") }), + }); + } + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(AppError::ValidationFailed { + message: "tag is required".into(), + details: json!({ "tag": "required" }), + }); + } + if trimmed.chars().any(|c| c.is_control()) { + return Err(AppError::ValidationFailed { + message: "tag contains control characters".into(), + details: json!({ "tag": "control_chars" }), + }); + } + if trimmed.chars().any(is_invisible_format) { + return Err(AppError::ValidationFailed { + message: "tag contains invisible / format characters".into(), + details: json!({ "tag": "invisible_chars" }), + }); + } + // Reject characters that would break the DELETE URL path + // (`/v1/pages/:id/tags/:tag`). axum decodes %2F back to `/` after + // routing, so a tag containing one of these would be silently + // unreachable to the delete handler — store-only. Reject up front + // so every stored tag is removable. `%` and `_` are also rejected + // since they are LIKE wildcards in the `list_for_user` prefix + // filter and would otherwise let a user accidentally search for + // anything. + if trimmed.chars().any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%' | '_')) { + return Err(AppError::ValidationFailed { + message: "tag contains a forbidden character (/ \\ ? # % _)".into(), + details: json!({ "tag": "forbidden_chars" }), + }); + } + // Lowercase + collapse internal whitespace runs (any kind: spaces, + // tabs, fullwidth space, etc.) into a single ASCII space. This + // keeps "Foo Bar" and "foo bar" identifying the same tag. + let normalized: String = trimmed + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" "); + // `namespace:value` is permitted but a bare leading/trailing colon + // is nonsense and would break any future split. + if normalized.starts_with(':') || normalized.ends_with(':') { + return Err(AppError::ValidationFailed { + message: "tag must not start or end with ':'".into(), + details: json!({ "tag": "edge_colon" }), + }); + } + if normalized.chars().count() > MAX_TAG_LEN { + // "After normalization" because Unicode case-folding can + // expand chars (Turkish capital `İ` → `i\u{307}`, so 33 İ's + // pass the wire-level 33-char input but trip this 64 cap). + return Err(AppError::ValidationFailed { + message: "tag too long after normalization".into(), + details: json!({ "tag": format!("max {MAX_TAG_LEN} characters") }), + }); + } + Ok(normalized) +} + +async fn add( + State(state): State, + CurrentUser(user): CurrentUser, + Path(page_id): Path, + Json(input): Json, +) -> AppResult { + let tag = normalize_tag(&input.tag)?; + let created = repo::page_tag::upsert(&state.db, user.id, page_id, &tag).await?; + Ok(if created { StatusCode::CREATED } else { StatusCode::OK }) +} + +async fn remove( + State(state): State, + CurrentUser(user): CurrentUser, + Path((page_id, tag)): Path<(Uuid, String)>, +) -> AppResult { + // Normalize the URL-decoded tag the same way add() did, so + // DELETE /pages/.../tags/Funny removes the row stored as "funny". + let normalized = normalize_tag(&tag)?; + repo::page_tag::remove(&state.db, user.id, page_id, &normalized).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_for_page( + State(state): State, + CurrentUser(user): CurrentUser, + Path(page_id): Path, +) -> AppResult> { + let tags = repo::page_tag::list_for_page(&state.db, user.id, page_id).await?; + Ok(Json(TagsResponse { tags })) +} + +async fn list_mine( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + // Filters from the wire arrive raw — normalize them so a filter on + // "Funny" matches rows stored as "funny". + let tag_filter = params + .tag + .as_deref() + .map(normalize_tag) + .transpose()?; + let prefix_filter = params + .q + .as_deref() + .map(normalize_tag) + .transpose()?; + let (items, total) = repo::page_tag::list_for_user( + &state.db, + user.id, + tag_filter.as_deref(), + prefix_filter.as_deref(), + limit, + offset, + ) + .await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn list_distinct_mine( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult> { + let limit = params.limit.clamp(1, 500); + let prefix = params + .q + .as_deref() + .map(normalize_tag) + .transpose()?; + let items = repo::page_tag::distinct_tags_for_user( + &state.db, + user.id, + prefix.as_deref(), + limit, + ) + .await?; + Ok(Json(DistinctResponse { items })) +} + +#[derive(Debug, Serialize)] +struct DistinctResponse { + items: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AggregateParams { + /// Required. Exact tag to aggregate by. Empty / whitespace-only + /// inputs are rejected with 422 via `normalize_tag`. + pub tag: String, + /// `desc` (default) or `asc`. Anything else → 422. + #[serde(default)] + pub order: Option, + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, + /// Reserved for the planned OCR text-search input. Accepted on + /// the wire so adding OCR later won't break the API shape, but + /// rejected with 501 `text_search_not_yet_supported` if non-empty + /// until the backend supports it. + #[serde(default)] + pub text: Option, +} + +fn parse_order(raw: Option<&str>) -> AppResult { + match raw.map(str::trim) { + None | Some("") | Some("desc") => Ok(Order::Desc), + Some("asc") => Ok(Order::Asc), + Some(other) => Err(AppError::ValidationFailed { + message: format!("order must be 'desc' or 'asc' (got {other:?})"), + details: json!({ "order": "invalid" }), + }), + } +} + +fn ensure_text_unsupported(text: Option<&str>) -> AppResult<()> { + // Future OCR search will plug in here. Until then, return a + // distinct code (`text_search_not_yet_supported`) so clients can + // detect "feature pending" vs. a generic 4xx — the code is the + // wire contract, not the message. + if text.is_some_and(|s| !s.trim().is_empty()) { + return Err(AppError::NotImplemented { + code: "text_search_not_yet_supported", + message: "text search is reserved for the planned OCR input but not yet supported", + }); + } + Ok(()) +} + +async fn list_chapters_for_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + ensure_text_unsupported(params.text.as_deref())?; + let tag = normalize_tag(¶ms.tag)?; + let order = parse_order(params.order.as_deref())?; + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let (items, total) = repo::page_tag::aggregate_chapters_for_tag( + &state.db, user.id, &tag, order, limit, offset, + ) + .await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn list_mangas_for_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + ensure_text_unsupported(params.text.as_deref())?; + let tag = normalize_tag(¶ms.tag)?; + let order = parse_order(params.order.as_deref())?; + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let (items, total) = repo::page_tag::aggregate_mangas_for_tag( + &state.db, user.id, &tag, order, limit, offset, + ) + .await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +#[cfg(test)] +mod tests { + use super::normalize_tag; + + #[test] + fn lowercases_and_collapses_whitespace() { + assert_eq!(normalize_tag("Funny").unwrap(), "funny"); + assert_eq!(normalize_tag(" Foo Bar ").unwrap(), "foo bar"); + assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd"); + } + + #[test] + fn rejects_blank_input() { + assert!(normalize_tag("").is_err()); + assert!(normalize_tag(" ").is_err()); + } + + #[test] + fn rejects_control_chars() { + assert!(normalize_tag("bad\ntag").is_err()); + assert!(normalize_tag("a\tb").is_err()); + // NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked + // here so a future stdlib drift surfaces as a test fail rather + // than a silent acceptance. + assert!(normalize_tag("a\u{0085}b").is_err()); + // NULL byte. + assert!(normalize_tag("a\u{0000}b").is_err()); + } + + #[test] + fn rejects_edge_colon() { + assert!(normalize_tag(":foo").is_err()); + assert!(normalize_tag("foo:").is_err()); + } + + #[test] + fn rejects_invisible_format_chars() { + // ZWJ — visually indistinguishable from no-zwj, would split + // "funny" and "funny\u{200d}" into distinct rows. + assert!(normalize_tag("funny\u{200d}").is_err()); + // RLO — bidi override could let a tag display backwards. + assert!(normalize_tag("a\u{202e}b").is_err()); + // ZWNBSP / BOM at the boundary survives `trim()`. + assert!(normalize_tag("a\u{feff}b").is_err()); + // ZWSP, RLM also rejected. + assert!(normalize_tag("a\u{200b}b").is_err()); + assert!(normalize_tag("a\u{200f}b").is_err()); + // Plane-14 LANGUAGE TAG + TAG-character block. These tunnel + // ASCII invisibly via the supplementary tag mechanism. + assert!(normalize_tag("a\u{E0001}b").is_err()); + assert!(normalize_tag("a\u{E0020}b").is_err()); + assert!(normalize_tag("a\u{E007F}b").is_err()); + } + + #[test] + fn rejects_path_breaking_and_like_wildcards() { + // `/` would survive %-encoding round-trip but axum decodes + // %2F back to `/` after path routing, so the DELETE path + // would never match. Same for `?`, `#`, `\`. + assert!(normalize_tag("a/b").is_err()); + assert!(normalize_tag("a\\b").is_err()); + assert!(normalize_tag("a?b").is_err()); + assert!(normalize_tag("a#b").is_err()); + // `%` and `_` are SQL LIKE wildcards in the prefix filter. + assert!(normalize_tag("a%b").is_err()); + assert!(normalize_tag("a_b").is_err()); + } + + #[test] + fn rejects_too_long() { + let long = "a".repeat(65); + assert!(normalize_tag(&long).is_err()); + let ok_len = "a".repeat(64); + assert!(normalize_tag(&ok_len).is_ok()); + } + + #[test] + fn rejects_pathologically_long_input_before_allocating() { + // 10 MiB of `a` — should fail-fast on the byte cap, not + // allocate twice through the lowercase/whitespace passes. + let huge = "a".repeat(10 * 1024 * 1024); + assert!(normalize_tag(&huge).is_err()); + // Just under cap still goes through the rest of the pipeline + // and fails on the char count. + let near_cap = "a".repeat(super::MAX_TAG_BYTES); + assert!(normalize_tag(&near_cap).is_err()); + } +} diff --git a/backend/src/domain/collection.rs b/backend/src/domain/collection.rs index 578fcb2..25e21df 100644 --- a/backend/src/domain/collection.rs +++ b/backend/src/domain/collection.rs @@ -33,6 +33,22 @@ pub struct CollectionSummary { pub sample_covers: Vec, } +/// Row returned by `GET /collections/:id/pages`. Joins through +/// `chapters` and `mangas` so the collection detail view can render a +/// thumbnail + breadcrumb without per-row follow-up fetches. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct CollectionPageItem { + pub page_id: Uuid, + pub chapter_id: Uuid, + pub manga_id: Uuid, + pub page_number: i32, + pub chapter_number: i32, + pub chapter_title: Option, + pub manga_title: String, + pub storage_key: String, + pub added_at: DateTime, +} + #[derive(Debug, Clone, Deserialize)] pub struct NewCollection { pub name: String, diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 517f075..6b60ca4 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -7,6 +7,7 @@ pub mod collection; pub mod genre; pub mod manga; pub mod page; +pub mod page_tag; pub mod patch; pub mod read_progress; pub mod session; @@ -21,10 +22,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 collection::{Collection, CollectionPageItem, CollectionSummary}; pub use genre::{Genre, GenreRef}; pub use manga::{Manga, MangaCard, MangaDetail}; pub use page::Page; +pub use page_tag::{ + NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate, + TaggedPageItem, +}; pub use patch::Patch; pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary}; pub use session::Session; diff --git a/backend/src/domain/page_tag.rs b/backend/src/domain/page_tag.rs new file mode 100644 index 0000000..fb0c5bf --- /dev/null +++ b/backend/src/domain/page_tag.rs @@ -0,0 +1,63 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize)] +pub struct NewPageTag { + pub tag: String, +} + +/// Returned by `GET /v1/me/page-tags`. Joins through chapters and +/// mangas so the library Page-tags tab can render the breadcrumb +/// without follow-up requests. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct TaggedPageItem { + pub tag: String, + pub page_id: Uuid, + pub chapter_id: Uuid, + pub manga_id: Uuid, + pub page_number: i32, + pub chapter_number: i32, + pub chapter_title: Option, + pub manga_title: String, + pub storage_key: String, + pub tagged_at: DateTime, +} + +/// Distinct-tags histogram row. Used both for autocomplete in the +/// "Add tag" sheet and for the chip cloud in the library Page-tags +/// tab. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PageTagSummary { + pub tag: String, + pub count: i64, +} + +/// One chapter (with breadcrumb) ranked by how many of its pages the +/// caller has tagged with a given tag. Returned by the `/search` +/// page's Chapters tab via `GET /v1/me/page-tags/chapters`. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct TaggedChapterAggregate { + pub chapter_id: Uuid, + pub manga_id: Uuid, + pub manga_title: String, + pub chapter_number: i32, + pub chapter_title: Option, + pub match_count: i64, + /// Up to 3 storage keys of matching pages in this chapter, + /// page-number ascending. Powers the thumbnail strip in the row. + pub sample_storage_keys: Vec, +} + +/// One manga ranked by how many of its pages (across all chapters) +/// the caller has tagged with a given tag. Returned by the `/search` +/// page's Mangas tab. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct TaggedMangaAggregate { + pub manga_id: Uuid, + pub manga_title: String, + pub manga_cover_image_path: Option, + pub match_count: i64, + pub sample_storage_keys: Vec, +} diff --git a/backend/src/error.rs b/backend/src/error.rs index 4bcb6d6..630a8cf 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -38,6 +38,16 @@ pub enum AppError { message: String, details: serde_json::Value, }, + /// 501 — the wire shape is accepted but the feature isn't built yet. + /// Carries a `&'static str` snake_case code so clients can detect + /// the specific pending feature (`text_search_not_yet_supported`, + /// etc.) without parsing the message. Used today by the `?text=` + /// reservation on the page-tag aggregation endpoints. + #[error("not implemented: {code}")] + NotImplemented { + code: &'static str, + message: &'static str, + }, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] @@ -64,6 +74,7 @@ impl AppError { AppError::ServiceUnavailable(_) => "service_unavailable", AppError::TooManyRequests { .. } => "too_many_requests", AppError::ValidationFailed { .. } => "validation_failed", + AppError::NotImplemented { code, .. } => code, AppError::Database(sqlx::Error::RowNotFound) => "not_found", AppError::Database(_) => "internal_error", AppError::Storage(StorageError::NotFound) => "not_found", @@ -124,6 +135,11 @@ impl IntoResponse for AppError { message.clone(), Some(details.clone()), ), + AppError::NotImplemented { message, .. } => ( + StatusCode::NOT_IMPLEMENTED, + (*message).to_string(), + None, + ), AppError::Database(sqlx::Error::RowNotFound) => { (StatusCode::NOT_FOUND, "not found".to_string(), None) } @@ -180,5 +196,15 @@ mod tests { assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found"); assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found"); assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error"); + // NotImplemented carries the code through so each pending + // feature gets its own stable identifier on the wire. + assert_eq!( + AppError::NotImplemented { + code: "text_search_not_yet_supported", + message: "x" + } + .code(), + "text_search_not_yet_supported" + ); } } diff --git a/backend/src/repo/collection.rs b/backend/src/repo/collection.rs index f9b028c..701d7f2 100644 --- a/backend/src/repo/collection.rs +++ b/backend/src/repo/collection.rs @@ -7,7 +7,7 @@ use sqlx::PgPool; use uuid::Uuid; -use crate::domain::collection::{Collection, CollectionSummary}; +use crate::domain::collection::{Collection, CollectionPageItem, CollectionSummary}; use crate::domain::manga::Manga; use crate::error::{AppError, AppResult}; @@ -278,3 +278,132 @@ pub async fn list_collections_containing( .await?; Ok(rows.into_iter().map(|(id,)| id).collect()) } + +/// Add a page to a collection. Same `(true → 201, false → 200)` +/// idempotency contract as `add_manga`. FK violations (page deleted +/// between the handler's existence check and this insert) surface as +/// `NotFound`, not a 500. +pub async fn add_page( + pool: &PgPool, + collection_id: Uuid, + page_id: Uuid, +) -> AppResult { + let mut tx = pool.begin().await?; + let inserted = sqlx::query( + r#" + INSERT INTO collection_pages (collection_id, page_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + ) + .bind(collection_id) + .bind(page_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_page( + pool: &PgPool, + collection_id: Uuid, + page_id: Uuid, +) -> AppResult<()> { + let mut tx = pool.begin().await?; + let rows_affected = sqlx::query( + "DELETE FROM collection_pages WHERE collection_id = $1 AND page_id = $2", + ) + .bind(collection_id) + .bind(page_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(()) +} + +/// Paged list of `collection_id`'s pages, JOINed through chapters and +/// mangas so each row carries the breadcrumb the detail view needs. +pub async fn list_pages( + pool: &PgPool, + collection_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let rows = sqlx::query_as::<_, CollectionPageItem>( + r#" + SELECT + p.id AS page_id, + p.chapter_id AS chapter_id, + ch.manga_id AS manga_id, + p.page_number AS page_number, + ch.number AS chapter_number, + ch.title AS chapter_title, + m.title AS manga_title, + p.storage_key AS storage_key, + cp.added_at AS added_at + FROM collection_pages cp + JOIN pages p ON p.id = cp.page_id + JOIN chapters ch ON ch.id = p.chapter_id + JOIN mangas m ON m.id = ch.manga_id + WHERE cp.collection_id = $1 + ORDER BY cp.added_at DESC, p.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_pages WHERE collection_id = $1") + .bind(collection_id) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +/// Which of `user_id`'s collections currently contain `page_id`? +/// Powers the reader context menu's "In N collections" line and the +/// "Add to collection" pre-check. +pub async fn list_collections_containing_page( + pool: &PgPool, + user_id: Uuid, + page_id: Uuid, +) -> AppResult> { + let rows: Vec<(Uuid,)> = sqlx::query_as( + r#" + SELECT c.id + FROM collections c + JOIN collection_pages cp ON cp.collection_id = c.id + WHERE c.user_id = $1 + AND cp.page_id = $2 + ORDER BY c.updated_at DESC + "#, + ) + .bind(user_id) + .bind(page_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index df347f3..38ea155 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -9,6 +9,7 @@ pub mod crawler; pub mod genre; pub mod manga; pub mod page; +pub mod page_tag; pub mod read_progress; pub mod session; pub mod tag; diff --git a/backend/src/repo/page_tag.rs b/backend/src/repo/page_tag.rs new file mode 100644 index 0000000..2a96994 --- /dev/null +++ b/backend/src/repo/page_tag.rs @@ -0,0 +1,374 @@ +//! Per-user, per-page tag persistence. +//! +//! Same plain-function pattern as the rest of `repo`. Idempotent +//! upserts via `ON CONFLICT DO NOTHING` (the caller distinguishes +//! 201/200 from the returned `bool`), FK-violation remap to NotFound so +//! the handler can return 404 when the page was deleted between an +//! existence check and the insert. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::page_tag::{ + PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate, TaggedPageItem, +}; +use crate::error::{AppError, AppResult}; + +/// Sort direction for the per-chapter / per-manga aggregations. We +/// inline this into the SQL via `format!()` because Postgres won't +/// accept ASC/DESC as a parameter — and the value space is closed +/// (just two variants), so there's no SQL-injection vector. +#[derive(Debug, Clone, Copy)] +pub enum Order { + Desc, + Asc, +} + +impl Order { + fn as_sql(self) -> &'static str { + match self { + Order::Desc => "DESC", + Order::Asc => "ASC", + } + } +} + +/// Insert a normalized tag for `(user_id, page_id)`. Returns `true` if +/// a new row was inserted (handler → 201), `false` if the tag was +/// already present (handler → 200). +pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + page_id: Uuid, + tag: &str, +) -> AppResult { + let result = sqlx::query( + r#" + INSERT INTO page_tags (user_id, page_id, tag) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, page_id, tag) DO NOTHING + "#, + ) + .bind(user_id) + .bind(page_id) + .bind(tag) + .execute(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), + })?; + Ok(result.rows_affected() > 0) +} + +pub async fn remove( + pool: &PgPool, + user_id: Uuid, + page_id: Uuid, + tag: &str, +) -> AppResult<()> { + sqlx::query( + "DELETE FROM page_tags WHERE user_id = $1 AND page_id = $2 AND tag = $3", + ) + .bind(user_id) + .bind(page_id) + .bind(tag) + .execute(pool) + .await?; + Ok(()) +} + +/// `user_id`'s tags on `page_id`, oldest-first so the context-menu +/// summary line reads in the order the user added them. +pub async fn list_for_page( + pool: &PgPool, + user_id: Uuid, + page_id: Uuid, +) -> AppResult> { + let rows: Vec<(String,)> = sqlx::query_as( + r#" + SELECT tag + FROM page_tags + WHERE user_id = $1 AND page_id = $2 + ORDER BY created_at ASC, tag + "#, + ) + .bind(user_id) + .bind(page_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(t,)| t).collect()) +} + +/// Escape a string for use as a LIKE pattern fragment: `%`, `_`, and +/// `\` get a leading backslash so they're matched literally rather +/// than as wildcards / escapes. The matching queries below pair this +/// with `ESCAPE '\'` for explicitness — a single backslash, since the +/// SQL lives in a raw string and Postgres treats `\\` in a single- +/// quoted literal as one backslash under `standard_conforming_strings`. +/// +/// The public API rejects `%`/`_`/`\` in `normalize_tag` before +/// they reach this repo, so this is defence-in-depth — a future +/// internal caller (worker, CLI) that bypasses the normalizer can't +/// turn a prefix filter into a wildcard search by accident. +fn escape_like_prefix(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + if matches!(ch, '\\' | '%' | '_') { + out.push('\\'); + } + out.push(ch); + } + out +} + +/// Paged list of `user_id`'s tagged pages, with breadcrumb. When +/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the +/// library Page-tags chip filter). `prefix_filter` does a `LIKE +/// 'prefix%'` against the normalized tag (used by autocomplete when +/// the user is typing a chip). The prefix is LIKE-escaped here so +/// stray `%` / `_` from a bypass-the-API caller don't widen the +/// pattern. +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + tag_filter: Option<&str>, + prefix_filter: Option<&str>, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let escaped_prefix = prefix_filter.map(escape_like_prefix); + let rows = sqlx::query_as::<_, TaggedPageItem>( + r#" + SELECT + pt.tag AS tag, + p.id AS page_id, + p.chapter_id AS chapter_id, + ch.manga_id AS manga_id, + p.page_number AS page_number, + ch.number AS chapter_number, + ch.title AS chapter_title, + m.title AS manga_title, + p.storage_key AS storage_key, + pt.created_at AS tagged_at + FROM page_tags pt + JOIN pages p ON p.id = pt.page_id + JOIN chapters ch ON ch.id = p.chapter_id + JOIN mangas m ON m.id = ch.manga_id + WHERE pt.user_id = $1 + AND ($2::text IS NULL OR pt.tag = $2) + AND ($3::text IS NULL OR pt.tag LIKE $3 || '%' ESCAPE '\') + ORDER BY pt.created_at DESC, pt.id + LIMIT $4 OFFSET $5 + "#, + ) + .bind(user_id) + .bind(tag_filter) + .bind(escaped_prefix.as_deref()) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let escaped_prefix = prefix_filter.map(escape_like_prefix); + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT count(*) + FROM page_tags + WHERE user_id = $1 + AND ($2::text IS NULL OR tag = $2) + AND ($3::text IS NULL OR tag LIKE $3 || '%' ESCAPE '\') + "#, + ) + .bind(user_id) + .bind(tag_filter) + .bind(escaped_prefix.as_deref()) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +/// Distinct tag list for the user, with per-tag counts. When `prefix` +/// is `Some(_)`, restrict to tags starting with that prefix — drives +/// the autocomplete dropdown in the "Add tag" sheet. The prefix is +/// LIKE-escaped here so stray `%` / `_` from a bypass-the-API caller +/// don't widen the pattern. +pub async fn distinct_tags_for_user( + pool: &PgPool, + user_id: Uuid, + prefix: Option<&str>, + limit: i64, +) -> AppResult> { + let escaped_prefix = prefix.map(escape_like_prefix); + let rows = sqlx::query_as::<_, PageTagSummary>( + r#" + SELECT tag, count(*) AS count + FROM page_tags + WHERE user_id = $1 + AND ($2::text IS NULL OR tag LIKE $2 || '%' ESCAPE '\') + GROUP BY tag + ORDER BY count DESC, tag + LIMIT $3 + "#, + ) + .bind(user_id) + .bind(escaped_prefix.as_deref()) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Paged list of chapters that contain pages tagged `tag` for `user_id`, +/// ranked by `match_count`. Each row carries up to 3 sample page +/// storage keys (page-number ascending) so the row can render a +/// thumbnail strip without a follow-up fetch. +/// +/// `order` is inlined via `format!()` — the enum value space is +/// closed (`ASC` / `DESC`) so this is not a SQL-injection vector. +pub async fn aggregate_chapters_for_tag( + pool: &PgPool, + user_id: Uuid, + tag: &str, + order: Order, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let sql = format!( + r#" + SELECT + ch.id AS chapter_id, + ch.manga_id AS manga_id, + m.title AS manga_title, + ch.number AS chapter_number, + ch.title AS chapter_title, + count(*) AS match_count, + COALESCE( + ( + SELECT array_agg(p2.storage_key ORDER BY p2.page_number ASC) + FROM ( + SELECT p.storage_key, p.page_number + FROM pages p + JOIN page_tags pt2 ON pt2.page_id = p.id + WHERE p.chapter_id = ch.id + AND pt2.user_id = $1 + AND pt2.tag = $2 + ORDER BY p.page_number ASC + LIMIT 3 + ) p2 + ), + ARRAY[]::text[] + ) AS sample_storage_keys + FROM page_tags pt + JOIN pages p ON p.id = pt.page_id + JOIN chapters ch ON ch.id = p.chapter_id + JOIN mangas m ON m.id = ch.manga_id + WHERE pt.user_id = $1 + AND pt.tag = $2 + GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title + ORDER BY match_count {dir}, ch.id + LIMIT $3 OFFSET $4 + "#, + dir = order.as_sql(), + ); + let rows = sqlx::query_as::<_, TaggedChapterAggregate>(&sql) + .bind(user_id) + .bind(tag) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT count(*) FROM ( + SELECT 1 + FROM page_tags pt + JOIN pages p ON p.id = pt.page_id + WHERE pt.user_id = $1 AND pt.tag = $2 + GROUP BY p.chapter_id + ) c + "#, + ) + .bind(user_id) + .bind(tag) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +/// Paged list of mangas containing pages tagged `tag` for `user_id`, +/// ranked by `match_count` summed across all their chapters. +pub async fn aggregate_mangas_for_tag( + pool: &PgPool, + user_id: Uuid, + tag: &str, + order: Order, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let sql = format!( + r#" + SELECT + m.id AS manga_id, + m.title AS manga_title, + m.cover_image_path AS manga_cover_image_path, + count(*) AS match_count, + COALESCE( + ( + SELECT array_agg(p2.storage_key ORDER BY p2.page_number ASC) + FROM ( + SELECT p.storage_key, p.page_number + FROM pages p + JOIN chapters ch2 ON ch2.id = p.chapter_id + JOIN page_tags pt2 ON pt2.page_id = p.id + WHERE ch2.manga_id = m.id + AND pt2.user_id = $1 + AND pt2.tag = $2 + ORDER BY p.page_number ASC + LIMIT 3 + ) p2 + ), + ARRAY[]::text[] + ) AS sample_storage_keys + FROM page_tags pt + JOIN pages p ON p.id = pt.page_id + JOIN chapters ch ON ch.id = p.chapter_id + JOIN mangas m ON m.id = ch.manga_id + WHERE pt.user_id = $1 + AND pt.tag = $2 + GROUP BY m.id, m.title, m.cover_image_path + ORDER BY match_count {dir}, m.id + LIMIT $3 OFFSET $4 + "#, + dir = order.as_sql(), + ); + let rows = sqlx::query_as::<_, TaggedMangaAggregate>(&sql) + .bind(user_id) + .bind(tag) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT count(*) FROM ( + SELECT 1 + FROM page_tags pt + JOIN pages p ON p.id = pt.page_id + JOIN chapters ch ON ch.id = p.chapter_id + WHERE pt.user_id = $1 AND pt.tag = $2 + GROUP BY ch.manga_id + ) m + "#, + ) + .bind(user_id) + .bind(tag) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + diff --git a/backend/tests/api_collection_pages.rs b/backend/tests/api_collection_pages.rs new file mode 100644 index 0000000..93de876 --- /dev/null +++ b/backend/tests/api_collection_pages.rs @@ -0,0 +1,331 @@ +//! Integration tests for the page-level collection endpoints: +//! +//! - `POST /v1/collections/:id/pages` +//! - `DELETE /v1/collections/:id/pages/:page_id` +//! - `GET /v1/collections/:id/pages` +//! - `GET /v1/pages/:id/my-collections` +//! +//! These exercise migration 0023's `collection_pages` table end-to-end, +//! plus the owner-only / 404-non-existence-leak / idempotency / cascade +//! invariants that mirror `api_collections.rs`. + +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 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); + common::body_json(resp).await +} + +/// Create a chapter with a single page so we have a real `pages.id` to +/// attach to. Returns `(chapter_id, page_id)`. +async fn seed_chapter_with_page( + app: &axum::Router, + cookie: &str, + manga_id: Uuid, + number: i32, +) -> (String, 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 chapter = common::body_json(resp).await; + let chapter_id = chapter["id"].as_str().unwrap().to_string(); + + let resp = app + .clone() + .oneshot(common::get_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let page_id = body["pages"][0]["id"].as_str().unwrap().to_string(); + (chapter_id, page_id) +} + +fn id_of(v: &Value) -> String { + v["id"].as_str().unwrap().to_string() +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_page_then_list_returns_breadcrumb(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, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + let coll = create_collection(&h.app, &cookie, "Favorite panels").await; + let coll_id = id_of(&coll); + + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages"), + json!({ "page_id": page_id }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages"), + &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(), 1); + let item = &items[0]; + assert_eq!(item["page_id"], page_id); + assert_eq!(item["chapter_id"], chapter_id); + assert_eq!(item["manga_id"], manga_id.to_string()); + assert_eq!(item["manga_title"], "Berserk"); + assert_eq!(item["chapter_number"], 1); + assert_eq!(item["page_number"], 1); + assert!( + item["storage_key"].as_str().unwrap().starts_with(&format!( + "mangas/{manga_id}/chapters/{chapter_id}/pages/" + )), + "unexpected storage_key: {}", + item["storage_key"] + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_page_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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + let coll = create_collection(&h.app, &cookie, "C").await; + let coll_id = id_of(&coll); + + let req = || { + common::post_json_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages"), + json!({ "page_id": page_id }), + &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_page_returns_404_when_page_missing(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 coll_id = id_of(&coll); + + let resp = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages"), + json!({ "page_id": Uuid::new_v4().to_string() }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_page_to_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 manga_id = common::seed_manga_via_api(&h.app, &b, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &b, manga_id, 1).await; + let coll_a = create_collection(&h.app, &a, "A's").await; + let coll_a_id = id_of(&coll_a); + + let resp = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/collections/{coll_a_id}/pages"), + json!({ "page_id": page_id }), + &b, + )) + .await + .unwrap(); + // Same non-leak semantic as the manga-level handler. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn remove_page_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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).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}/pages"), + json!({ "page_id": page_id }), + &cookie, + )) + .await + .unwrap(); + + let first = h + .app + .clone() + .oneshot(common::delete_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages/{page_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::NO_CONTENT); + let second = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages/{page_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::NO_CONTENT); +} + +#[sqlx::test(migrations = "./migrations")] +async fn my_collections_for_page_lists_only_owned(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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &a, manga_id, 1).await; + + let a_coll = create_collection(&h.app, &a, "A").await; + let b_coll = create_collection(&h.app, &b, "B").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}/pages"), + json!({ "page_id": page_id }), + cookie, + )) + .await + .unwrap(); + } + + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/pages/{page_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 my_collections_for_unknown_page_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/pages/{}/my-collections", Uuid::new_v4()), + &cookie, + )) + .await + .unwrap(); + 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 add_page_requires_authentication(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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + let coll = create_collection(&h.app, &cookie, "C").await; + let coll_id = id_of(&coll); + + let resp = h + .app + .oneshot(common::post_json( + &format!("/api/v1/collections/{coll_id}/pages"), + json!({ "page_id": page_id }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_pages_in_others_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 coll_id = id_of(&coll); + + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/collections/{coll_id}/pages"), + &b, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/backend/tests/api_page_tags.rs b/backend/tests/api_page_tags.rs new file mode 100644 index 0000000..e648678 --- /dev/null +++ b/backend/tests/api_page_tags.rs @@ -0,0 +1,646 @@ +//! Integration tests for the page-tag endpoints: +//! +//! - `POST /v1/pages/:id/tags` +//! - `DELETE /v1/pages/:id/tags/:tag` +//! - `GET /v1/pages/:id/my-tags` +//! - `GET /v1/me/page-tags` +//! - `GET /v1/me/page-tags/distinct` +//! - `GET /v1/me/page-tags/chapters` +//! - `GET /v1/me/page-tags/mangas` +//! +//! Together with `repo::page_tag` and migration 0023's `page_tags` +//! table. Validation behaviour and the page-deletion cascade are +//! pinned here. + +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_with_page( + app: &axum::Router, + cookie: &str, + manga_id: Uuid, + number: i32, +) -> (String, 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 chapter = common::body_json(resp).await; + let chapter_id = chapter["id"].as_str().unwrap().to_string(); + + let resp = app + .clone() + .oneshot(common::get_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"), + cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + let page_id = body["pages"][0]["id"].as_str().unwrap().to_string(); + (chapter_id, page_id) +} + +/// Like `seed_chapter_with_page` but uploads `n` pages so the +/// aggregation tests can tag a subset and verify match counts. +/// Returns `(chapter_id, page_ids_in_order)`. +async fn seed_chapter_with_n_pages( + app: &axum::Router, + cookie: &str, + manga_id: Uuid, + number: i32, + n: usize, +) -> (String, Vec) { + let mut builder = MultipartBuilder::new() + .add_json("metadata", json!({ "number": number })); + for i in 0..n { + let fname = format!("{:04}.png", i + 1); + builder = builder.add_file("page", &fname, "image/png", &common::fake_png_bytes()); + } + let resp = app + .clone() + .oneshot(common::post_multipart_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters"), + builder, + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let chapter = common::body_json(resp).await; + let chapter_id = chapter["id"].as_str().unwrap().to_string(); + + let resp = app + .clone() + .oneshot(common::get_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"), + cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + let page_ids: Vec = body["pages"] + .as_array() + .unwrap() + .iter() + .map(|p| p["id"].as_str().unwrap().to_string()) + .collect(); + (chapter_id, page_ids) +} + +async fn add_tag( + app: &axum::Router, + cookie: &str, + page_id: &str, + tag: &str, +) -> StatusCode { + let resp = app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/pages/{page_id}/tags"), + json!({ "tag": tag }), + cookie, + )) + .await + .unwrap(); + resp.status() +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_then_list_returns_normalized_tag(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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + assert_eq!(add_tag(&h.app, &cookie, &page_id, " Funny ").await, StatusCode::CREATED); + + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/pages/{page_id}/my-tags"), + &cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + // Stored as normalized "funny". + assert_eq!(body["tags"], json!(["funny"])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_is_idempotent_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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED); + // Even after re-casing, the normalized form collides → 200. + assert_eq!(add_tag(&h.app, &cookie, &page_id, "FUNNY").await, StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_unknown_page_is_404(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( + &format!("/api/v1/pages/{}/tags", Uuid::new_v4()), + json!({ "tag": "funny" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_blank_tag_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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + let resp = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/pages/{page_id}/tags"), + json!({ "tag": " " }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_tag_too_long_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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + let long = "a".repeat(65); + let resp = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/pages/{page_id}/tags"), + json!({ "tag": long }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_tag_with_invisible_format_char_is_422(pool: PgPool) { + // Without the format-char guard, `"funny"` and `"funny\u{200d}"` + // (zero-width joiner appended) would be stored as distinct rows, + // visually identical to the user and indistinguishable in the + // chip cloud. Same risk for bidi overrides and BOM. + 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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + for bad in [ + "funny\u{200d}", // ZWJ + "a\u{202e}b", // RLO + "a\u{feff}b", // ZWNBSP / BOM + "a\u{200b}b", // ZWSP + ] { + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/pages/{page_id}/tags"), + json!({ "tag": bad }), + &cookie, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "expected 422 for tag containing invisible char (bytes: {:?})", + bad.as_bytes() + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_tag_with_path_breaking_char_is_422(pool: PgPool) { + // Without this guard, `"a/b"` would be storable via POST (JSON + // body) but unremovable via DELETE (axum decodes %2F back to `/` + // and the `:tag` segment never matches). The 422 keeps every + // stored tag round-trippable. + 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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + for bad in ["a/b", "a?b", "a#b", "a%b", "a_b", "a\\b"] { + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/pages/{page_id}/tags"), + json!({ "tag": bad }), + &cookie, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "expected 422 for tag {bad:?}" + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +async fn add_tag_with_control_char_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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + let resp = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/pages/{page_id}/tags"), + json!({ "tag": "bad\ntag" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test(migrations = "./migrations")] +async fn remove_normalizes_url_tag(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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED); + + // DELETE arrives with the original case; the handler renormalizes + // so it still matches "funny" in storage. + let resp = h + .app + .clone() + .oneshot(common::delete_with_cookie( + &format!("/api/v1/pages/{page_id}/tags/FUNNY"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/pages/{page_id}/my-tags"), + &cookie, + )) + .await + .unwrap(); + let body: Value = common::body_json(resp).await; + assert_eq!(body["tags"], json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_mine_filters_by_tag(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 (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + + assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED); + assert_eq!(add_tag(&h.app, &cookie, &page_id, "fight").await, StatusCode::CREATED); + assert_eq!(add_tag(&h.app, &cookie, &page_id, "panel-of-the-day").await, StatusCode::CREATED); + + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie("/api/v1/me/page-tags?tag=fight", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["tag"], "fight"); + assert_eq!(items[0]["page_id"], page_id); + assert_eq!(items[0]["manga_title"], "M"); + + // Prefix filter ?q=fu matches "funny". + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/page-tags?q=fu", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + let tags: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|v| v["tag"].as_str().unwrap()) + .collect(); + assert_eq!(tags, vec!["funny"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn distinct_lists_counts_per_tag(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 (_, p1) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await; + let (_, p2) = seed_chapter_with_page(&h.app, &cookie, manga_id, 2).await; + + // "funny" appears twice; "fight" once. Distinct should reflect that. + assert_eq!(add_tag(&h.app, &cookie, &p1, "funny").await, StatusCode::CREATED); + assert_eq!(add_tag(&h.app, &cookie, &p2, "funny").await, StatusCode::CREATED); + assert_eq!(add_tag(&h.app, &cookie, &p1, "fight").await, StatusCode::CREATED); + + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/page-tags/distinct", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + // Ordered by count DESC, tag ASC. + assert_eq!(items[0]["tag"], "funny"); + assert_eq!(items[0]["count"], 2); + assert_eq!(items[1]["tag"], "fight"); + assert_eq!(items[1]["count"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn tags_are_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, "M").await; + let (_, page_id) = seed_chapter_with_page(&h.app, &a, manga_id, 1).await; + + assert_eq!(add_tag(&h.app, &a, &page_id, "funny").await, StatusCode::CREATED); + + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/pages/{page_id}/my-tags"), + &b, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["tags"], json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn tags_require_authentication(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::post_json( + &format!("/api/v1/pages/{}/tags", Uuid::new_v4()), + json!({ "tag": "funny" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// ------------------------------------------------------------------- +// Aggregation endpoints (`/me/page-tags/{chapters,mangas}`). +// ------------------------------------------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn chapters_aggregate_groups_and_ranks_by_match_count(pool: PgPool) { + // Chapter A in manga Berserk gets 3 pages tagged "funny"; + // chapter B (same manga) gets 1. Desc order → A first. + 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_a, pages_a) = + seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await; + let (chapter_b, pages_b) = + seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 2).await; + + for p in &pages_a { + assert_eq!(add_tag(&h.app, &cookie, p, "funny").await, StatusCode::CREATED); + } + assert_eq!(add_tag(&h.app, &cookie, &pages_b[0], "funny").await, StatusCode::CREATED); + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=funny", + &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(), 2); + assert_eq!(items[0]["chapter_id"], chapter_a); + assert_eq!(items[0]["match_count"], 3); + // Up to 3 sample storage keys, ordered by page_number ASC. + let samples_a = items[0]["sample_storage_keys"].as_array().unwrap(); + assert_eq!(samples_a.len(), 3); + assert_eq!(items[1]["chapter_id"], chapter_b); + assert_eq!(items[1]["match_count"], 1); + assert_eq!(body["page"]["total"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn chapters_aggregate_respects_asc_order(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 (chapter_a, pages_a) = + seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await; + let (chapter_b, pages_b) = + seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 1).await; + for p in &pages_a { + let _ = add_tag(&h.app, &cookie, p, "funny").await; + } + let _ = add_tag(&h.app, &cookie, &pages_b[0], "funny").await; + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=funny&order=asc", + &cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + // Lowest count first. + assert_eq!(items[0]["chapter_id"], chapter_b); + assert_eq!(items[0]["match_count"], 1); + assert_eq!(items[1]["chapter_id"], chapter_a); + assert_eq!(items[1]["match_count"], 3); +} + +#[sqlx::test(migrations = "./migrations")] +async fn mangas_aggregate_sums_across_chapters(pool: PgPool) { + // Two chapters in the same manga, each contributes to the same + // manga's match_count. + 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 (_, pages_a) = + seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await; + let (_, pages_b) = + seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 2).await; + for p in pages_a.iter().chain(pages_b.iter()) { + let _ = add_tag(&h.app, &cookie, p, "funny").await; + } + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/mangas?tag=funny", + &cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["manga_id"], manga_id.to_string()); + assert_eq!(items[0]["match_count"], 5); + assert_eq!(items[0]["manga_title"], "Berserk"); + let samples = items[0]["sample_storage_keys"].as_array().unwrap(); + assert_eq!(samples.len(), 3, "manga sample preview capped at 3"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn aggregate_other_users_tags_are_excluded(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, "M").await; + let (_, pages) = seed_chapter_with_n_pages(&h.app, &a, manga_id, 1, 2).await; + // A tags everything funny; B tags nothing. + for p in &pages { + let _ = add_tag(&h.app, &a, p, "funny").await; + } + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=funny", + &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 aggregate_unknown_tag_returns_empty_paged_response(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=neverused", + &cookie, + )) + .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); +} + +#[sqlx::test(migrations = "./migrations")] +async fn aggregate_rejects_missing_tag(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test(migrations = "./migrations")] +async fn aggregate_rejects_invalid_order(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=funny&order=sideways", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test(migrations = "./migrations")] +async fn aggregate_with_text_param_is_501_with_stable_code(pool: PgPool) { + // OCR text search isn't built yet; the param is accepted so adding + // OCR won't break the wire shape, but rejected with a distinct + // status + code. The code is the wire contract — clients pin on + // `text_search_not_yet_supported`, not the message. + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/me/page-tags/chapters?tag=funny&text=guts", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "text_search_not_yet_supported"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn aggregate_requires_authentication(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/me/page-tags/chapters?tag=funny")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/frontend/e2e/back-nav-flow.spec.ts b/frontend/e2e/back-nav-flow.spec.ts index 1d44293..d27168a 100644 --- a/frontend/e2e/back-nav-flow.spec.ts +++ b/frontend/e2e/back-nav-flow.spec.ts @@ -53,7 +53,13 @@ async function mockApis(page: Page) { ); // Catalog returns a single card whose href points at MID so the // SPA-click walk lands on the manga we've mocked downstream. - await page.route('**/api/v1/mangas', (r) => + // `**/api/v1/mangas` alone doesn't intercept query strings + // (`?limit=&sort=`), so the catalog request would fall through + // to a real backend if one is running on :8080 and seed the + // page with real-manga IDs that don't match the mock body. + // `**/api/v1/mangas?**` (and the same suffix on the catch-all + // below) makes the intercept query-tolerant. + await page.route('**/api/v1/mangas?**', (r) => r.fulfill({ status: 200, contentType: 'application/json', @@ -163,4 +169,56 @@ test('phone viewport: reader back pops history (does not push), then detail back await page.waitForURL((url) => url.pathname === '/'); }); +test('reader cover+title pushes detail when arrived from a non-detail page', async ({ + page +}) => { + // The smart-cover-title fix: if the user got to the reader from + // a page OTHER than this manga's detail (search, library, + // direct link, etc.), the cover+title should push the detail + // page so browser-back returns to the reader. The arrow stays + // a pure "go back". + // + // We simulate "non-detail arrival" with a deep-link load + // straight to the reader. That covers cold-tab + shared-link + + // search-result cases — afterNavigate fires with from=null and + // lastInternalPath stays null, so the cover+title click sees + // a mismatch and pushes the detail page. + await mockApis(page); + await page.setViewportSize({ width: 390, height: 844 }); + + await page.goto(`/manga/${MID}/chapter/${CID}`); + await page.waitForSelector('[data-testid="back-to-manga"]'); + const lenAtReader = await page.evaluate(() => window.history.length); + + await page.getByTestId('back-to-manga').click(); + await page.waitForURL(/\/manga\/[^/]+$/); + const lenAfter = await page.evaluate(() => window.history.length); + // PUSH (not pop) — lenAtReader + 1. + expect(lenAfter).toBe(lenAtReader + 1); + + // Browser-back from detail returns to reader (proves it was a + // push, not a replace). + await page.goBack(); + await page.waitForURL(/\/chapter\//); +}); + +test('reader arrow always pops history', async ({ page }) => { + await mockApis(page); + await page.setViewportSize({ width: 390, height: 844 }); + + await page.goto('/'); + await page.locator('[data-testid="manga-list"] a').first().click(); + await page.waitForURL(/\/manga\/[^/]+$/); + await page.locator('[data-testid="chapter-list"] a').first().click(); + await page.waitForURL(/\/chapter\//); + + const lenAtReader = await page.evaluate(() => window.history.length); + + // Click the arrow — always pops, regardless of previous page. + await page.getByTestId('reader-back-arrow').click(); + await page.waitForURL(/\/manga\/[^/]+$/); + const lenAfter = await page.evaluate(() => window.history.length); + expect(lenAfter).toBe(lenAtReader); +}); + }); diff --git a/frontend/e2e/page-context-menu.spec.ts b/frontend/e2e/page-context-menu.spec.ts new file mode 100644 index 0000000..08d8f36 --- /dev/null +++ b/frontend/e2e/page-context-menu.spec.ts @@ -0,0 +1,330 @@ +import { test, expect, type Page } from '@playwright/test'; + +// E2E for the per-page collection + tag flow added in v0.61.0. Mocks +// the entire API so the spec runs without a backend. The same +// fixtures + mockReader scaffolding as `mobile-reader.spec.ts`; +// kept inline so the file stays self-contained. + +const MOBILE = { width: 390, height: 844 } as const; +const DESKTOP = { width: 1280, height: 720 } as const; + +const mangaId = 'a9999999-9999-9999-9999-999999999999'; +const chapterId = 'c9999999-9999-9999-9999-999999999999'; +const pageId = 'p11111111-1111-1111-1111-111111111111'; +const collectionId = 'cc111111-1111-1111-1111-111111111111'; + +const mangaFixture = { + id: mangaId, + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: `mangas/${mangaId}/cover.png`, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'au1', name: 'Kentaro Miura' }], + genres: [], + tags: [] +}; + +const chapterFixture = { + id: chapterId, + manga_id: mangaId, + number: 1, + title: 'The Brand', + page_count: 1, + created_at: '2026-01-01T00:00:00Z' +}; + +const pagesFixture = [ + { + id: pageId, + chapter_id: chapterId, + page_number: 1, + storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`, + content_type: 'image/png' + } +]; + +const userFixture = { + id: 'u11111111-1111-1111-1111-111111111111', + username: 'tester', + created_at: '2026-01-01T00:00:00Z', + is_admin: false +}; + +const collectionFixture = { + id: collectionId, + user_id: userFixture.id, + name: 'Favorite panels', + description: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + manga_count: 0, + sample_covers: [] +}; + +/** + * Wire the full mock surface for the reader plus the new + * `pages/:id/my-collections`, `pages/:id/my-tags`, `me/collections`, + * and `collections/:id/pages` endpoints. `myCollectionsState` lets a + * test mutate what /my-collections returns mid-flow so the "re-open + * menu after add → In 1 collection" assertion is exercised. + */ +async function mockReader( + page: Page, + state: { collectionsContainingPage: string[]; tagsOnPage: string[] } +) { + await page.route('**/api/v1/auth/config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + reader_mode: 'single', + reader_page_gap: 'small' + }) + }) + ); + 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/mangas/${mangaId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaFixture) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [chapterFixture], + page: { limit: 50, offset: 0, total: 1 } + }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters\\?*`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [chapterFixture], + page: { limit: 50, offset: 0, total: 1 } + }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(chapterFixture) + }) + ); + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: pagesFixture }) + }) + ); + await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'not_found', message: 'not found' } }) + }) + ); + + // The new endpoints — these are what the context menu hits. + await page.route(`**/api/v1/pages/${pageId}/my-collections`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ collection_ids: state.collectionsContainingPage }) + }) + ); + await page.route(`**/api/v1/pages/${pageId}/my-tags`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ tags: state.tagsOnPage }) + }) + ); + await page.route('**/api/v1/me/collections*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [collectionFixture], + page: { limit: 200, offset: 0, total: 1 } + }) + }) + ); + await page.route( + `**/api/v1/collections/${collectionId}/pages`, + async (route) => { + if (route.request().method() === 'POST') { + state.collectionsContainingPage = [collectionId]; + return route.fulfill({ status: 201, body: '' }); + } + return route.continue(); + } + ); + + const png = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', + 'hex' + ); + await page.route('**/api/v1/files/**', (route) => + route.fulfill({ status: 200, contentType: 'image/png', body: png }) + ); +} + +test.describe('page context menu (desktop right-click)', () => { + test('right-click opens the menu with empty-state hints', async ({ page }) => { + const state = { collectionsContainingPage: [], tagsOnPage: [] }; + await mockReader(page, state); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + await expect(page.getByTestId('reader-page')).toBeVisible(); + await expect(page.getByTestId('page-context-menu')).toBeHidden(); + + await page.getByTestId('reader-page').click({ button: 'right' }); + + const menu = page.getByTestId('page-context-menu'); + await expect(menu).toBeVisible(); + await expect( + page.getByTestId('page-context-collections-line') + ).toHaveText('Not in any collection'); + await expect(page.getByTestId('page-context-tags-line')).toHaveText( + 'No tags yet' + ); + }); + + test('Escape closes the menu', async ({ page }) => { + await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + await page.getByTestId('reader-page').click({ button: 'right' }); + await expect(page.getByTestId('page-context-menu')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByTestId('page-context-menu')).toBeHidden(); + }); + + test('add-to-collection → modal → toggle → re-open menu shows "In 1 collection"', async ({ + page + }) => { + const state = { collectionsContainingPage: [], tagsOnPage: [] }; + await mockReader(page, state); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + await page.getByTestId('reader-page').click({ button: 'right' }); + await page.getByTestId('page-context-add-to-collection').click(); + + // Modal opens with the user's one collection unchecked. + const modal = page.getByTestId('add-to-collection-modal'); + await expect(modal).toBeVisible(); + const checkbox = modal.getByTestId(`collection-toggle-${collectionId}`); + await expect(checkbox).not.toBeChecked(); + + await checkbox.check(); + // The mock flips containing-page state on POST. Close + re-open + // the menu to verify the contextual line reflects the new + // server state. + await page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + + await page.getByTestId('reader-page').click({ button: 'right' }); + await expect(page.getByTestId('page-context-collections-line')).toHaveText( + 'In 1 collection' + ); + }); + + test('Shift+right-click falls through to the browser native menu', async ({ + page + }) => { + await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + // Hold Shift while right-clicking. The reader's + // `oncontextmenu` returns early on shiftKey, so the in-app + // menu must NOT open. (Playwright doesn't surface the + // native browser menu, but we can assert ours stays hidden.) + await page.keyboard.down('Shift'); + await page.getByTestId('reader-page').click({ button: 'right' }); + await page.keyboard.up('Shift'); + + await expect(page.getByTestId('page-context-menu')).toBeHidden(); + }); +}); + +test.describe('page action sheet (mobile long-press)', () => { + test('long-press on a page image opens the action sheet', async ({ page }) => { + await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + // Switch to continuous mode so the per-image long-press + // handler is wired (single mode goes through TapZone). Both + // codepaths funnel into the same Sheet, but the per-image + // wiring is the riskier of the two — it's the one the audit + // flagged for the multitouch fix. + await page.getByTestId('reader-settings-btn').click(); + await page + .getByTestId('reader-settings-sheet') + .getByRole('radio', { name: 'Continuous' }) + .click(); + // Close the settings sheet so its scrim isn't blocking. + await page.keyboard.press('Escape'); + + const pageEl = page.getByTestId('reader-page-1'); + await expect(pageEl).toBeVisible(); + + // Synthesize a touch pointerdown, wait past the 450ms timer. + const box = await pageEl.boundingBox(); + if (!box) throw new Error('page image has no bounding box'); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + + await pageEl.dispatchEvent('pointerdown', { + pointerType: 'touch', + clientX: cx, + clientY: cy, + bubbles: true + }); + await page.waitForTimeout(550); + + await expect(page.getByTestId('page-action-sheet')).toBeVisible(); + await expect(page.getByTestId('page-action-add-to-collection')).toBeVisible(); + await expect(page.getByTestId('page-action-add-tag')).toBeVisible(); + await expect(page.getByTestId('page-action-save-image')).toBeVisible(); + await expect(page.getByTestId('page-action-copy-link')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/reader-page-deep-link.spec.ts b/frontend/e2e/reader-page-deep-link.spec.ts new file mode 100644 index 0000000..8ce3cd3 --- /dev/null +++ b/frontend/e2e/reader-page-deep-link.spec.ts @@ -0,0 +1,250 @@ +import { test, expect, type Page } from '@playwright/test'; + +// E2E for the `?page=N` deep-link path in both reader modes. The +// single-mode case has been working since v0.x; the continuous-mode +// scroll-to-page landed in v0.62.0 alongside the /search Pages tab. + +const mangaId = 'a9999999-9999-9999-9999-999999999999'; +const chapterId = 'c9999999-9999-9999-9999-999999999999'; +const chapterBId = 'c8888888-8888-8888-8888-888888888888'; + +const sixPages = Array.from({ length: 6 }, (_, i) => ({ + id: `p${i + 1}1111111-1111-1111-1111-111111111111`, + chapter_id: chapterId, + page_number: i + 1, + storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/000${i + 1}.png`, + content_type: 'image/png' +})); + +const fourPages = Array.from({ length: 4 }, (_, i) => ({ + id: `p${i + 1}2222222-2222-2222-2222-222222222222`, + chapter_id: chapterBId, + page_number: i + 1, + storage_key: `mangas/${mangaId}/chapters/${chapterBId}/pages/000${i + 1}.png`, + content_type: 'image/png' +})); + +const userFixture = { + id: 'u11111111-1111-1111-1111-111111111111', + username: 'tester', + created_at: '2026-01-01T00:00:00Z', + is_admin: false +}; + +const mangaFixture = { + id: mangaId, + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: `mangas/${mangaId}/cover.png`, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'au1', name: 'Kentaro Miura' }], + genres: [], + tags: [] +}; + +const chapterFixture = { + id: chapterId, + manga_id: mangaId, + number: 1, + title: 'The Brand', + page_count: sixPages.length, + created_at: '2026-01-01T00:00:00Z' +}; + +const chapterBFixture = { + id: chapterBId, + manga_id: mangaId, + number: 2, + title: 'Guardians of Desire', + page_count: fourPages.length, + created_at: '2026-01-02T00:00:00Z' +}; + +async function mockReader(page: Page, mode: 'single' | 'continuous') { + await page.route('**/api/v1/auth/config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }) + ); + // Critical for this spec: the server-stored preference seeds + // `preferences.readerMode` at hydration time. The new continuous- + // mode scroll-to-page effect re-fires when `mode` flips from + // its initial 'single' default to the hydrated value. + await page.route('**/api/v1/auth/me/preferences', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ reader_mode: mode, reader_page_gap: 'small' }) + }) + ); + 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/mangas/${mangaId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaFixture) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [chapterFixture, chapterBFixture], + page: { limit: 50, offset: 0, total: 2 } + }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(chapterFixture) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterBId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(chapterBFixture) + }) + ); + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: sixPages }) + }) + ); + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterBId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: fourPages }) + }) + ); + await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'not_found', message: 'not found' } }) + }) + ); + const png = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', + 'hex' + ); + await page.route('**/api/v1/files/**', (route) => + route.fulfill({ status: 200, contentType: 'image/png', body: png }) + ); +} + +test.describe('reader ?page=N deep link', () => { + test('continuous mode: pages 1..N are eager-loaded so the scroll target has settled height', async ({ + page + }) => { + await mockReader(page, 'continuous'); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}?page=4`); + + await expect(page.getByTestId('reader-continuous')).toBeVisible(); + + // Pages 1..=4 (1-indexed in the testid, 0-indexed in the + // template; initialIndex = 3 means we eager-load 0..=3, i.e. + // testids 1..4). Without this guard, page 2+ would be lazy + // and their 0×0 placeholders would let the scroll target + // appear far above its final position. + for (const n of [1, 2, 3, 4]) { + await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( + 'loading', + 'eager' + ); + } + // Pages beyond the target stay lazy. + for (const n of [5, 6]) { + await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( + 'loading', + 'lazy' + ); + } + }); + + test('continuous mode: no ?page= eager-loads only the first two', async ({ + page + }) => { + await mockReader(page, 'continuous'); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + await expect(page.getByTestId('reader-continuous')).toBeVisible(); + await expect(page.getByTestId('reader-page-1')).toHaveAttribute( + 'loading', + 'eager' + ); + await expect(page.getByTestId('reader-page-2')).toHaveAttribute( + 'loading', + 'eager' + ); + await expect(page.getByTestId('reader-page-3')).toHaveAttribute( + 'loading', + 'lazy' + ); + }); + + test('single mode: ?page=N opens at the requested page', async ({ page }) => { + await mockReader(page, 'single'); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}?page=5`); + + await expect(page.getByTestId('reader-page')).toBeVisible(); + await expect(page.getByTestId('page-indicator')).toHaveText('Page 5 / 6'); + }); + + test('in-reader chapter selector resets state for the new chapter', async ({ + page + }) => { + // Deep-link into chapter A at page 5. SvelteKit reuses the + // component when we navigate to chapter B via the chapter + // selector, so `index` (and the read-progress sentinel) + // have to be reset by the page's chapter-change effect. + // Without it, page-indicator would read "Page 5 / 4" (old + // index, new pages.length) and the next progress flush would + // poison chapter B's stored read-progress with chapter A's + // high-water mark. + await mockReader(page, 'single'); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}?page=5`); + await expect(page.getByTestId('page-indicator')).toHaveText( + 'Page 5 / 6' + ); + + await page + .getByTestId('reader-chapter-select') + .selectOption(chapterBId); + + await expect(page).toHaveURL( + new RegExp(`/manga/${mangaId}/chapter/${chapterBId}$`) + ); + await expect(page.getByTestId('page-indicator')).toHaveText( + 'Page 1 / 4' + ); + }); +}); diff --git a/frontend/e2e/search.spec.ts b/frontend/e2e/search.spec.ts new file mode 100644 index 0000000..d14f12b --- /dev/null +++ b/frontend/e2e/search.spec.ts @@ -0,0 +1,251 @@ +import { test, expect, type Page } from '@playwright/test'; + +// E2E for the /search page shipped in v0.62.0. Five scenarios against +// mocked endpoints — no backend needed. + +const DESKTOP = { width: 1280, height: 720 } as const; + +const userFixture = { + id: 'u11111111-1111-1111-1111-111111111111', + username: 'tester', + created_at: '2026-01-01T00:00:00Z', + is_admin: false +}; + +const mangaId = 'a9999999-9999-9999-9999-999999999999'; +const chapterId = 'c9999999-9999-9999-9999-999999999999'; +const pageId = 'p11111111-1111-1111-1111-111111111111'; + +const distinct = [ + { tag: 'funny', count: 38 }, + { tag: 'fight', count: 24 } +]; + +const pagesResponse = { + items: [ + { + tag: 'funny', + page_id: pageId, + chapter_id: chapterId, + manga_id: mangaId, + page_number: 5, + chapter_number: 1, + chapter_title: null, + manga_title: 'Berserk', + storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`, + tagged_at: '2026-01-01T00:00:00Z' + } + ], + page: { limit: 100, offset: 0, total: 1 } +}; + +const chaptersDesc = { + items: [ + { + chapter_id: chapterId, + manga_id: mangaId, + manga_title: 'Berserk', + chapter_number: 1, + chapter_title: null, + match_count: 12, + sample_storage_keys: [ + `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`, + `mangas/${mangaId}/chapters/${chapterId}/pages/0002.png`, + `mangas/${mangaId}/chapters/${chapterId}/pages/0003.png` + ] + }, + { + chapter_id: 'c8888888-8888-8888-8888-888888888888', + manga_id: mangaId, + manga_title: 'Berserk', + chapter_number: 2, + chapter_title: null, + match_count: 3, + sample_storage_keys: [] + } + ], + page: { limit: 100, offset: 0, total: 2 } +}; + +// Same fixture reversed by the mock when `order=asc` is requested, +// so the test can assert the order flip end-to-end. +const chaptersAsc = { + items: [...chaptersDesc.items].reverse(), + page: chaptersDesc.page +}; + +const mangasResponse = { + items: [ + { + manga_id: mangaId, + manga_title: 'Berserk', + manga_cover_image_path: `mangas/${mangaId}/cover.png`, + match_count: 28, + sample_storage_keys: [ + `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png` + ] + } + ], + page: { limit: 100, offset: 0, total: 1 } +}; + +async function mockSearch(page: Page) { + await page.route('**/api/v1/auth/config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: userFixture }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' }) + }) + ); + 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/page-tags/distinct*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: distinct }) + }) + ); + await page.route('**/api/v1/me/page-tags?**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(pagesResponse) + }) + ); + await page.route('**/api/v1/me/page-tags/chapters*', (route) => { + const u = new URL(route.request().url()); + const body = + u.searchParams.get('order') === 'asc' ? chaptersAsc : chaptersDesc; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body) + }); + }); + await page.route('**/api/v1/me/page-tags/mangas*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangasResponse) + }) + ); + + // PNG stub for fileUrl(storage_key) thumbnails. + const png = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', + 'hex' + ); + await page.route('**/api/v1/files/**', (route) => + route.fulfill({ status: 200, contentType: 'image/png', body: png }) + ); +} + +test.describe('/search', () => { + test('empty /search renders the chip cloud; clicking a chip sets ?tag=', async ({ + page + }) => { + await mockSearch(page); + await page.setViewportSize(DESKTOP); + await page.goto('/search'); + + await expect(page.getByTestId('search-chip-cloud')).toBeVisible(); + await expect(page.getByTestId('search-chip-funny')).toBeVisible(); + await expect(page.getByTestId('search-chip-fight')).toBeVisible(); + + await page.getByTestId('search-chip-funny').click(); + await expect(page).toHaveURL(/[?&]tag=funny/); + await expect(page.getByTestId('search-active-tag')).toContainText('funny'); + }); + + test('Pages tab shows results; clicking a row navigates to reader at ?page=N', async ({ + page + }) => { + await mockSearch(page); + await page.setViewportSize(DESKTOP); + await page.goto('/search?tag=funny'); + + await expect(page.getByTestId('search-pages-list')).toBeVisible(); + const row = page.getByTestId(`search-page-row-${pageId}`); + await expect(row).toBeVisible(); + + // The breadcrumb link goes to the reader at ?page=5. + const breadcrumbLink = row.locator('a.target'); + await expect(breadcrumbLink).toHaveAttribute( + 'href', + `/manga/${mangaId}/chapter/${chapterId}?page=5` + ); + }); + + test('Chapters tab calls /chapters endpoint and renders ranked rows', async ({ + page + }) => { + await mockSearch(page); + await page.setViewportSize(DESKTOP); + await page.goto('/search?tag=funny&view=chapters'); + + await expect(page.getByTestId('search-chapters-list')).toBeVisible(); + // Default desc order — first row = highest match count (12). + const rows = page.getByTestId('search-chapters-list').locator('li'); + await expect(rows.nth(0)).toContainText('12 pages'); + await expect(rows.nth(1)).toContainText('3 pages'); + + // Chapter row links to the reader at the chapter root. + const firstTitle = rows.nth(0).locator('a.title').first(); + await expect(firstTitle).toHaveAttribute( + 'href', + `/manga/${mangaId}/chapter/${chapterId}` + ); + }); + + test('Order toggle flips chapter rows', async ({ page }) => { + await mockSearch(page); + await page.setViewportSize(DESKTOP); + await page.goto('/search?tag=funny&view=chapters'); + + // Click the "Fewest pages" segmented control. + await page + .getByTestId('search-sort') + .getByRole('radio', { name: 'Fewest pages' }) + .click(); + + await expect(page).toHaveURL(/[?&]order=asc/); + const rows = page.getByTestId('search-chapters-list').locator('li'); + await expect(rows.nth(0)).toContainText('3 pages'); + await expect(rows.nth(1)).toContainText('12 pages'); + }); + + test('Mangas tab renders rows linking to manga detail', async ({ page }) => { + await mockSearch(page); + await page.setViewportSize(DESKTOP); + await page.goto('/search?tag=funny&view=mangas'); + + await expect(page.getByTestId('search-mangas-list')).toBeVisible(); + const row = page.getByTestId(`search-manga-row-${mangaId}`); + await expect(row).toContainText('Berserk'); + await expect(row).toContainText('28 pages'); + await expect(row.locator('a.title')).toHaveAttribute( + 'href', + `/manga/${mangaId}` + ); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index bafdb17..4aef920 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.60.2", + "version": "0.62.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/page_collections.test.ts b/frontend/src/lib/api/page_collections.test.ts new file mode 100644 index 0000000..c7a13e3 --- /dev/null +++ b/frontend/src/lib/api/page_collections.test.ts @@ -0,0 +1,101 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance +} from 'vitest'; +import { + addPageToCollection, + removePageFromCollection, + listCollectionPages, + getMyCollectionsContainingPage +} from './page_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 pageItemFixture(extra: Record = {}) { + return { + page_id: 'p1', + chapter_id: 'ch1', + manga_id: 'm1', + page_number: 1, + chapter_number: 1, + chapter_title: null, + manga_title: 'Berserk', + storage_key: 'mangas/m1/chapters/ch1/pages/0001.png', + added_at: '2026-01-01T00:00:00Z', + ...extra + }; +} + +describe('page_collections api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listCollectionPages hits the breadcrumb endpoint', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [pageItemFixture()], + page: { limit: 50, offset: 0, total: 1 } + }) + ); + const r = await listCollectionPages('c1'); + expect(r.items[0].manga_title).toBe('Berserk'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/collections\/c1\/pages$/); + }); + + it('listCollectionPages forwards pagination params', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 10, offset: 20, total: 0 } }) + ); + await listCollectionPages('c1', { limit: 10, offset: 20 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + }); + + it('addPageToCollection POSTs { page_id }', async () => { + fetchSpy.mockResolvedValueOnce(ok({}, 201)); + await addPageToCollection('c1', 'p1'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ page_id: 'p1' }); + }); + + it('removePageFromCollection DELETEs with both ids encoded', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await removePageFromCollection('c with space', 'p1'); + const url = fetchSpy.mock.calls[0][0] as string; + // Path includes encoded "c with space" + page id. + expect(url).toContain('/v1/collections/c%20with%20space/pages/p1'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + }); + + it('getMyCollectionsContainingPage unwraps `collection_ids`', async () => { + fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c2'] })); + const ids = await getMyCollectionsContainingPage('p1'); + expect(ids).toEqual(['c1', 'c2']); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/pages\/p1\/my-collections$/); + }); +}); diff --git a/frontend/src/lib/api/page_collections.ts b/frontend/src/lib/api/page_collections.ts new file mode 100644 index 0000000..6f73761 --- /dev/null +++ b/frontend/src/lib/api/page_collections.ts @@ -0,0 +1,68 @@ +import { request, type Page } from './client'; + +/** Row returned by `GET /v1/collections/:id/pages`. */ +export type CollectionPageItem = { + page_id: string; + chapter_id: string; + manga_id: string; + page_number: number; + chapter_number: number; + chapter_title: string | null; + manga_title: string; + storage_key: string; + added_at: string; +}; + +export type CollectionPagesPage = { + items: CollectionPageItem[]; + page: Page; +}; + +export type ListOptions = { limit?: number; offset?: number }; + +export async function listCollectionPages( + collectionId: string, + opts: ListOptions = {} +): Promise { + 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( + `/v1/collections/${encodeURIComponent(collectionId)}/pages${qs ? `?${qs}` : ''}` + ); +} + +export async function addPageToCollection( + collectionId: string, + pageId: string +): Promise { + await request( + `/v1/collections/${encodeURIComponent(collectionId)}/pages`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ page_id: pageId }) + } + ); +} + +export async function removePageFromCollection( + collectionId: string, + pageId: string +): Promise { + await request( + `/v1/collections/${encodeURIComponent(collectionId)}/pages/${encodeURIComponent(pageId)}`, + { method: 'DELETE' } + ); +} + +/** Which of the user's collections currently contain this page. */ +export async function getMyCollectionsContainingPage( + pageId: string +): Promise { + const r = await request<{ collection_ids: string[] }>( + `/v1/pages/${encodeURIComponent(pageId)}/my-collections` + ); + return r.collection_ids; +} diff --git a/frontend/src/lib/api/page_tags.test.ts b/frontend/src/lib/api/page_tags.test.ts new file mode 100644 index 0000000..46e3366 --- /dev/null +++ b/frontend/src/lib/api/page_tags.test.ts @@ -0,0 +1,166 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance +} from 'vitest'; +import { + getMyTagsForPage, + addTagToPage, + removeTagFromPage, + listMyPageTags, + listMyDistinctPageTags, + listTaggedChapters, + listTaggedMangas +} from './page_tags'; + +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 taggedItemFixture(extra: Record = {}) { + return { + tag: 'funny', + page_id: 'p1', + chapter_id: 'ch1', + manga_id: 'm1', + page_number: 1, + chapter_number: 1, + chapter_title: null, + manga_title: 'Berserk', + storage_key: 'mangas/m1/chapters/ch1/pages/0001.png', + tagged_at: '2026-01-01T00:00:00Z', + ...extra + }; +} + +describe('page_tags api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('getMyTagsForPage unwraps `tags`', async () => { + fetchSpy.mockResolvedValueOnce(ok({ tags: ['funny', 'fight'] })); + const tags = await getMyTagsForPage('p1'); + expect(tags).toEqual(['funny', 'fight']); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/pages\/p1\/my-tags$/); + }); + + it('addTagToPage POSTs { tag }', async () => { + fetchSpy.mockResolvedValueOnce(ok({}, 201)); + await addTagToPage('p1', 'funny'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/pages\/p1\/tags$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ tag: 'funny' }); + }); + + it('removeTagFromPage URL-encodes the tag', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await removeTagFromPage('p1', 'character:askeladd'); + const url = fetchSpy.mock.calls[0][0] as string; + // ':' becomes %3A under encodeURIComponent. + expect(url).toContain('/v1/pages/p1/tags/character%3Aaskeladd'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + }); + + it('listMyPageTags forwards tag / q / pagination params', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [taggedItemFixture()], + page: { limit: 50, offset: 0, total: 1 } + }) + ); + await listMyPageTags({ tag: 'funny', q: 'fu', limit: 10, offset: 20 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('tag=funny'); + expect(url).toContain('q=fu'); + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + }); + + it('listMyPageTags omits query string when no params', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + ); + await listMyPageTags(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/page-tags$/); + }); + + it('listMyDistinctPageTags unwraps `items`', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [ + { tag: 'funny', count: 5 }, + { tag: 'fight', count: 2 } + ] + }) + ); + const summaries = await listMyDistinctPageTags(); + expect(summaries.map((s) => s.tag)).toEqual(['funny', 'fight']); + }); + + it('listMyDistinctPageTags sends only `q` when prefix supplied', async () => { + fetchSpy.mockResolvedValueOnce(ok({ items: [] })); + await listMyDistinctPageTags('fu'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('q=fu'); + }); + + it('listTaggedChapters always sends tag, order/limit/offset are optional', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + ); + await listTaggedChapters({ tag: 'funny' }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/page-tags\/chapters\?tag=funny$/); + }); + + it('listTaggedChapters forwards order + pagination', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 25, offset: 10, total: 0 } }) + ); + await listTaggedChapters({ + tag: 'funny', + order: 'asc', + limit: 25, + offset: 10 + }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('tag=funny'); + expect(url).toContain('order=asc'); + expect(url).toContain('limit=25'); + expect(url).toContain('offset=10'); + }); + + it('listTaggedMangas hits the mangas endpoint', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + ); + await listTaggedMangas({ tag: 'funny' }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/); + }); +}); diff --git a/frontend/src/lib/api/page_tags.ts b/frontend/src/lib/api/page_tags.ts new file mode 100644 index 0000000..0392777 --- /dev/null +++ b/frontend/src/lib/api/page_tags.ts @@ -0,0 +1,145 @@ +import { request, type Page } from './client'; + +/** Row returned by `GET /v1/me/page-tags`. */ +export type TaggedPageItem = { + tag: string; + page_id: string; + chapter_id: string; + manga_id: string; + page_number: number; + chapter_number: number; + chapter_title: string | null; + manga_title: string; + storage_key: string; + tagged_at: string; +}; + +export type MyPageTagsPage = { + items: TaggedPageItem[]; + page: Page; +}; + +export type PageTagSummary = { + tag: string; + count: number; +}; + +export type ListMineOptions = { + /** Exact-match filter (chip filter in the library tab). */ + tag?: string; + /** Prefix filter (autocomplete-style). */ + q?: string; + limit?: number; + offset?: number; +}; + +export async function getMyTagsForPage(pageId: string): Promise { + const r = await request<{ tags: string[] }>( + `/v1/pages/${encodeURIComponent(pageId)}/my-tags` + ); + return r.tags; +} + +export async function addTagToPage(pageId: string, tag: string): Promise { + await request(`/v1/pages/${encodeURIComponent(pageId)}/tags`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tag }) + }); +} + +export async function removeTagFromPage(pageId: string, tag: string): Promise { + await request( + `/v1/pages/${encodeURIComponent(pageId)}/tags/${encodeURIComponent(tag)}`, + { method: 'DELETE' } + ); +} + +export async function listMyPageTags( + opts: ListMineOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.tag != null) params.set('tag', opts.tag); + if (opts.q != null) params.set('q', opts.q); + 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(`/v1/me/page-tags${qs ? `?${qs}` : ''}`); +} + +export async function listMyDistinctPageTags( + prefix?: string, + limit?: number +): Promise { + const params = new URLSearchParams(); + if (prefix != null && prefix !== '') params.set('q', prefix); + if (limit != null) params.set('limit', String(limit)); + const qs = params.toString(); + const r = await request<{ items: PageTagSummary[] }>( + `/v1/me/page-tags/distinct${qs ? `?${qs}` : ''}` + ); + return r.items; +} + +/** Row returned by `GET /v1/me/page-tags/chapters`. */ +export type TaggedChapterAggregate = { + chapter_id: string; + manga_id: string; + manga_title: string; + chapter_number: number; + chapter_title: string | null; + match_count: number; + /** Up to 3 storage keys of matching pages, page-number ascending. */ + sample_storage_keys: string[]; +}; + +/** Row returned by `GET /v1/me/page-tags/mangas`. */ +export type TaggedMangaAggregate = { + manga_id: string; + manga_title: string; + manga_cover_image_path: string | null; + match_count: number; + sample_storage_keys: string[]; +}; + +export type TaggedChaptersPage = { + items: TaggedChapterAggregate[]; + page: Page; +}; + +export type TaggedMangasPage = { + items: TaggedMangaAggregate[]; + page: Page; +}; + +export type AggregateOptions = { + tag: string; + order?: 'desc' | 'asc'; + limit?: number; + offset?: number; +}; + +function aggregateQs(opts: AggregateOptions): string { + const params = new URLSearchParams(); + params.set('tag', opts.tag); + if (opts.order != null) params.set('order', opts.order); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + return params.toString(); +} + +export async function listTaggedChapters( + opts: AggregateOptions +): Promise { + return request( + `/v1/me/page-tags/chapters?${aggregateQs(opts)}` + ); +} + +export async function listTaggedMangas( + opts: AggregateOptions +): Promise { + return request( + `/v1/me/page-tags/mangas?${aggregateQs(opts)}` + ); +} diff --git a/frontend/src/lib/components/AddTagsSheet.svelte b/frontend/src/lib/components/AddTagsSheet.svelte new file mode 100644 index 0000000..f9e6e9a --- /dev/null +++ b/frontend/src/lib/components/AddTagsSheet.svelte @@ -0,0 +1,315 @@ + + +
+ {#if loading} +

Loading tags…

+ {:else} +
+ {#each tags as t (t)} + + {t} + + + {/each} + {#if tags.length === 0} + No tags yet — add one below. + {/if} +
+ {/if} + +
+ + +
+ + {#if filteredSuggestions.length > 0} +
+

Suggestions

+
+ {#each filteredSuggestions as s (s.tag)} + + {/each} +
+
+ {/if} + + {#if error} + + {/if} +
+ + diff --git a/frontend/src/lib/components/AddTagsSheet.svelte.test.ts b/frontend/src/lib/components/AddTagsSheet.svelte.test.ts new file mode 100644 index 0000000..b25da76 --- /dev/null +++ b/frontend/src/lib/components/AddTagsSheet.svelte.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import AddTagsSheet from './AddTagsSheet.svelte'; + +const pageTagsApi = vi.hoisted(() => ({ + getMyTagsForPage: vi.fn(), + addTagToPage: vi.fn(), + removeTagFromPage: vi.fn(), + listMyDistinctPageTags: vi.fn() +})); + +vi.mock('$lib/api/page_tags', () => pageTagsApi); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function settle() { + await tick(); + await Promise.resolve(); + await tick(); +} + +describe('AddTagsSheet', () => { + it('renders current tags as chips after load', async () => { + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny', 'fight']); + pageTagsApi.listMyDistinctPageTags.mockResolvedValueOnce([]); + + render(AddTagsSheet, { props: { pageId: 'p1' } }); + await settle(); + + expect(screen.getByTestId('add-tags-remove-funny')).toBeTruthy(); + expect(screen.getByTestId('add-tags-remove-fight')).toBeTruthy(); + }); + + it('renders suggestions excluding already-applied tags', async () => { + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny']); + pageTagsApi.listMyDistinctPageTags.mockResolvedValueOnce([ + { tag: 'funny', count: 5 }, + { tag: 'fight', count: 3 } + ]); + + render(AddTagsSheet, { props: { pageId: 'p1' } }); + await settle(); + + expect(screen.queryByTestId('add-tags-suggest-funny')).toBeNull(); + expect(screen.getByTestId('add-tags-suggest-fight')).toBeTruthy(); + }); + + it('add posts then refetches normalized tag list', async () => { + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce([]); + // Two calls: initial load + post-add refreshSuggestions. + pageTagsApi.listMyDistinctPageTags.mockResolvedValue([]); + pageTagsApi.addTagToPage.mockResolvedValueOnce(undefined); + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny']); + + const onChange = vi.fn(); + render(AddTagsSheet, { props: { pageId: 'p1', onChange } }); + await settle(); + + const input = screen.getByTestId('add-tags-input') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'Funny' } }); + await fireEvent.click(screen.getByTestId('add-tags-submit')); + await settle(); + + expect(pageTagsApi.addTagToPage).toHaveBeenCalledWith('p1', 'Funny'); + expect(pageTagsApi.getMyTagsForPage).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledWith(['funny']); + // Stored as normalized — chip shows lowercase. + expect(screen.getByTestId('add-tags-remove-funny')).toBeTruthy(); + }); + + it('restores focus to the input after a successful add', async () => { + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce([]); + pageTagsApi.listMyDistinctPageTags.mockResolvedValue([]); + pageTagsApi.addTagToPage.mockResolvedValueOnce(undefined); + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny']); + + render(AddTagsSheet, { props: { pageId: 'p1' } }); + await settle(); + + const input = screen.getByTestId('add-tags-input') as HTMLInputElement; + input.focus(); + await fireEvent.input(input, { target: { value: 'funny' } }); + await fireEvent.click(screen.getByTestId('add-tags-submit')); + await settle(); + // Drain the queueMicrotask scheduled in `add()`'s finally so + // the focus-restore call has actually run. + await Promise.resolve(); + await Promise.resolve(); + + expect(document.activeElement).toBe(input); + }); + + it('remove deletes and updates chip list', async () => { + pageTagsApi.getMyTagsForPage.mockResolvedValueOnce(['funny', 'fight']); + pageTagsApi.listMyDistinctPageTags.mockResolvedValueOnce([]); + pageTagsApi.removeTagFromPage.mockResolvedValueOnce(undefined); + + const onChange = vi.fn(); + render(AddTagsSheet, { props: { pageId: 'p1', onChange } }); + await settle(); + + await fireEvent.click(screen.getByTestId('add-tags-remove-funny')); + await settle(); + + expect(pageTagsApi.removeTagFromPage).toHaveBeenCalledWith('p1', 'funny'); + expect(screen.queryByTestId('add-tags-remove-funny')).toBeNull(); + expect(onChange).toHaveBeenCalledWith(['fight']); + }); +}); diff --git a/frontend/src/lib/components/AddToCollectionModal.svelte b/frontend/src/lib/components/AddToCollectionModal.svelte index e830316..37f9499 100644 --- a/frontend/src/lib/components/AddToCollectionModal.svelte +++ b/frontend/src/lib/components/AddToCollectionModal.svelte @@ -8,15 +8,32 @@ removeMangaFromCollection, type CollectionSummary } from '$lib/api/collections'; + import { + addPageToCollection, + getMyCollectionsContainingPage, + removePageFromCollection + } from '$lib/api/page_collections'; import Plus from '@lucide/svelte/icons/plus'; + /** + * Discriminated-union target so the same modal serves both the + * manga-detail "Add to collection" flow and the reader's page + * context menu. The list / pre-check / toggle / create-and-add + * scaffolding is identical for both — only the leaf API call + * differs, dispatched through `loadContaining` / `addTo` / + * `removeFrom` below. + */ + export type Target = + | { kind: 'manga'; id: string } + | { kind: 'page'; id: string }; + let { open, - mangaId, + target, onClose }: { open: boolean; - mangaId: string; + target: Target; onClose: () => void; } = $props(); @@ -28,23 +45,42 @@ 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). + // Refetch on open and whenever the target changes (e.g., the user + // right-clicks a different page while the modal is in flight). The + // dependency on `target.id` is explicit so the $effect re-runs. $effect(() => { if (open) { + // Touching target.id wires the reactive dependency. + void target.id; void load(); } }); + async function loadContaining(t: Target): Promise { + return t.kind === 'manga' + ? getMyCollectionsContaining(t.id) + : getMyCollectionsContainingPage(t.id); + } + + async function addTo(collectionId: string, t: Target): Promise { + return t.kind === 'manga' + ? addMangaToCollection(collectionId, t.id) + : addPageToCollection(collectionId, t.id); + } + + async function removeFrom(collectionId: string, t: Target): Promise { + return t.kind === 'manga' + ? removeMangaFromCollection(collectionId, t.id) + : removePageFromCollection(collectionId, t.id); + } + async function load() { loading = true; error = null; try { const [page, ids] = await Promise.all([ listMyCollections({ limit: 200 }), - getMyCollectionsContaining(mangaId) + loadContaining(target) ]); collections = page.items; containingIds = new Set(ids); @@ -55,9 +91,6 @@ } } - // 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(s: Set, v: T): Set { const n = new Set(s); n.add(v); @@ -72,21 +105,27 @@ 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); + await removeFrom(collection.id, target); + // manga_count drifts when pages are toggled — the + // backend doesn't track page_count yet, so we only + // adjust the count for the manga path. Page targets + // leave manga_count as-is. + if (target.kind === 'manga') { + collection.manga_count = Math.max(0, collection.manga_count - 1); + } } else { - await addMangaToCollection(collection.id, mangaId); - collection.manga_count += 1; + await addTo(collection.id, target); + if (target.kind === 'manga') { + 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); @@ -103,15 +142,11 @@ 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); + await addTo(created.id, target); collections = [ { ...created, - manga_count: 1, + manga_count: target.kind === 'manga' ? 1 : 0, sample_covers: [] }, ...collections @@ -156,10 +191,20 @@ /> {c.name} - - {c.manga_count} - {c.manga_count === 1 ? 'manga' : 'mangas'} - + {#if target.kind === 'manga'} + + + {c.manga_count} + {c.manga_count === 1 ? 'manga' : 'mangas'} + + {/if} diff --git a/frontend/src/lib/components/AddToCollectionModal.svelte.test.ts b/frontend/src/lib/components/AddToCollectionModal.svelte.test.ts new file mode 100644 index 0000000..47a22aa --- /dev/null +++ b/frontend/src/lib/components/AddToCollectionModal.svelte.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import AddToCollectionModal from './AddToCollectionModal.svelte'; + +// Stub the API modules at the import-graph level so the modal exercises +// its dispatch (manga vs page) without hitting fetch. +const collectionsApi = vi.hoisted(() => ({ + listMyCollections: vi.fn(), + getMyCollectionsContaining: vi.fn(), + addMangaToCollection: vi.fn(), + removeMangaFromCollection: vi.fn(), + createCollection: vi.fn() +})); +const pageCollectionsApi = vi.hoisted(() => ({ + getMyCollectionsContainingPage: vi.fn(), + addPageToCollection: vi.fn(), + removePageFromCollection: vi.fn() +})); + +vi.mock('$lib/api/collections', () => collectionsApi); +vi.mock('$lib/api/page_collections', () => pageCollectionsApi); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function summary(id: string, name: string, containing: string[] = []) { + return { + id, + user_id: 'u1', + name, + description: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + manga_count: 0, + sample_covers: [], + _containing: containing + }; +} + +describe('AddToCollectionModal', () => { + it('manga target loads via getMyCollectionsContaining', async () => { + collectionsApi.listMyCollections.mockResolvedValueOnce({ + items: [summary('c1', 'Favorites')], + page: { limit: 200, offset: 0, total: 1 } + }); + collectionsApi.getMyCollectionsContaining.mockResolvedValueOnce(['c1']); + + render(AddToCollectionModal, { + props: { + open: true, + target: { kind: 'manga', id: 'm1' }, + onClose: () => {} + } + }); + // Wait for the $effect → load() → await chain to settle. + await tick(); + await Promise.resolve(); + await tick(); + + expect(collectionsApi.getMyCollectionsContaining).toHaveBeenCalledWith('m1'); + expect(pageCollectionsApi.getMyCollectionsContainingPage).not.toHaveBeenCalled(); + }); + + it('page target loads via getMyCollectionsContainingPage', async () => { + collectionsApi.listMyCollections.mockResolvedValueOnce({ + items: [summary('c1', 'Favorite panels')], + page: { limit: 200, offset: 0, total: 1 } + }); + pageCollectionsApi.getMyCollectionsContainingPage.mockResolvedValueOnce([]); + + render(AddToCollectionModal, { + props: { + open: true, + target: { kind: 'page', id: 'p1' }, + onClose: () => {} + } + }); + await tick(); + await Promise.resolve(); + await tick(); + + expect(pageCollectionsApi.getMyCollectionsContainingPage).toHaveBeenCalledWith('p1'); + expect(collectionsApi.getMyCollectionsContaining).not.toHaveBeenCalled(); + expect(screen.getByText('Favorite panels')).toBeTruthy(); + }); + + it('does not load when closed', () => { + render(AddToCollectionModal, { + props: { + open: false, + target: { kind: 'manga', id: 'm1' }, + onClose: () => {} + } + }); + expect(collectionsApi.listMyCollections).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/components/PageContextMenu.svelte b/frontend/src/lib/components/PageContextMenu.svelte new file mode 100644 index 0000000..84448a0 --- /dev/null +++ b/frontend/src/lib/components/PageContextMenu.svelte @@ -0,0 +1,231 @@ + + +{#if open} + + +{/if} + + diff --git a/frontend/src/lib/components/PageContextMenu.svelte.test.ts b/frontend/src/lib/components/PageContextMenu.svelte.test.ts new file mode 100644 index 0000000..904c612 --- /dev/null +++ b/frontend/src/lib/components/PageContextMenu.svelte.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; +import PageContextMenu from './PageContextMenu.svelte'; + +afterEach(() => cleanup()); + +const baseProps = { + open: true, + anchor: { x: 100, y: 100 }, + onClose: () => {}, + onAddToCollection: () => {}, + onAddTag: () => {}, + onSaveImage: () => {}, + onCopyLink: () => {}, + collectionsCount: 0, + tags: [] as string[] +}; + +describe('PageContextMenu', () => { + it('renders all four action items', () => { + render(PageContextMenu, { props: baseProps }); + expect(screen.getByTestId('page-context-add-to-collection')).toBeTruthy(); + expect(screen.getByTestId('page-context-add-tag')).toBeTruthy(); + expect(screen.getByTestId('page-context-save-image')).toBeTruthy(); + expect(screen.getByTestId('page-context-copy-link')).toBeTruthy(); + }); + + it('invokes onSaveImage and onCopyLink when their items are clicked', () => { + const onSaveImage = vi.fn(); + const onCopyLink = vi.fn(); + render(PageContextMenu, { + props: { ...baseProps, onSaveImage, onCopyLink } + }); + screen.getByTestId('page-context-save-image').click(); + screen.getByTestId('page-context-copy-link').click(); + expect(onSaveImage).toHaveBeenCalledOnce(); + expect(onCopyLink).toHaveBeenCalledOnce(); + }); + + it('renders empty-state contextual lines when count=0 and tags=[]', () => { + render(PageContextMenu, { props: baseProps }); + expect(screen.getByTestId('page-context-collections-line').textContent).toBe( + 'Not in any collection' + ); + expect(screen.getByTestId('page-context-tags-line').textContent).toBe( + 'No tags yet' + ); + }); + + it('renders loading line when collectionsCount is null', () => { + render(PageContextMenu, { + props: { ...baseProps, collectionsCount: null } + }); + expect(screen.getByTestId('page-context-collections-line').textContent).toBe( + 'Loading…' + ); + }); + + it('renders pluralized line when in multiple collections', () => { + render(PageContextMenu, { + props: { ...baseProps, collectionsCount: 3 } + }); + expect(screen.getByTestId('page-context-collections-line').textContent).toBe( + 'In 3 collections' + ); + }); + + it('renders singular line when in one collection', () => { + render(PageContextMenu, { + props: { ...baseProps, collectionsCount: 1 } + }); + expect(screen.getByTestId('page-context-collections-line').textContent).toBe( + 'In 1 collection' + ); + }); + + it('joins tags with commas', () => { + render(PageContextMenu, { + props: { ...baseProps, tags: ['funny', 'fight', 'panel-of-the-day'] } + }); + expect(screen.getByTestId('page-context-tags-line').textContent).toBe( + 'Tagged: funny, fight, panel-of-the-day' + ); + }); + + it('invokes callbacks when items are clicked', () => { + const onAddToCollection = vi.fn(); + const onAddTag = vi.fn(); + render(PageContextMenu, { + props: { ...baseProps, onAddToCollection, onAddTag } + }); + screen.getByTestId('page-context-add-to-collection').click(); + screen.getByTestId('page-context-add-tag').click(); + expect(onAddToCollection).toHaveBeenCalledOnce(); + expect(onAddTag).toHaveBeenCalledOnce(); + }); + + it('closes on Escape', async () => { + const onClose = vi.fn(); + render(PageContextMenu, { props: { ...baseProps, onClose } }); + await fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('does not render when closed', () => { + render(PageContextMenu, { props: { ...baseProps, open: false } }); + expect(screen.queryByTestId('page-context-menu')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/PageTagsList.svelte b/frontend/src/lib/components/PageTagsList.svelte new file mode 100644 index 0000000..d1daca7 --- /dev/null +++ b/frontend/src/lib/components/PageTagsList.svelte @@ -0,0 +1,144 @@ + + +{#if initialDistinct.length === 0 && items.length === 0} +

+ You haven't tagged any pages yet. Open a chapter and right-click + (or long-press on mobile) a page to add a tag. +

+{:else} + {#if initialDistinct.length > 0} +
+ {#each initialDistinct as s (s.tag)} + + {/each} +
+ {/if} + + {#if error} + + {/if} + + {#if loading} +

Loading…

+ {:else if items.length === 0} +

+ No pages tagged with "{active}". +

+ {:else} +
    + {#each items as p (`${p.tag}::${p.page_id}`)} + + {/each} +
+ {/if} +{/if} + + diff --git a/frontend/src/lib/components/PageTagsList.svelte.test.ts b/frontend/src/lib/components/PageTagsList.svelte.test.ts new file mode 100644 index 0000000..677c5ca --- /dev/null +++ b/frontend/src/lib/components/PageTagsList.svelte.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import PageTagsList from './PageTagsList.svelte'; + +const pageTagsApi = vi.hoisted(() => ({ + listMyPageTags: vi.fn() +})); + +vi.mock('$lib/api/page_tags', async () => { + const actual = + await vi.importActual( + '$lib/api/page_tags' + ); + return { ...actual, ...pageTagsApi }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function tagged(extra: Record = {}) { + return { + tag: 'funny', + page_id: 'p1', + chapter_id: 'ch1', + manga_id: 'm1', + page_number: 1, + chapter_number: 1, + chapter_title: null, + manga_title: 'Berserk', + storage_key: 'mangas/m1/chapters/ch1/pages/0001.png', + tagged_at: '2026-01-01T00:00:00Z', + ...extra + }; +} + +describe('PageTagsList', () => { + it('renders empty state when nothing is tagged', () => { + render(PageTagsList, { + props: { initialItems: [], initialDistinct: [] } + }); + expect(screen.getByTestId('library-page-tags-empty')).toBeTruthy(); + }); + + it('renders chip cloud and list from initial props', () => { + render(PageTagsList, { + props: { + initialItems: [tagged({ tag: 'funny' })], + initialDistinct: [ + { tag: 'funny', count: 5 }, + { tag: 'fight', count: 2 } + ] + } + }); + expect(screen.getByTestId('library-page-tags-chip-funny')).toBeTruthy(); + expect(screen.getByTestId('library-page-tags-chip-fight')).toBeTruthy(); + expect(screen.getByTestId('library-page-tags-list')).toBeTruthy(); + }); + + it('clicking a chip filters via listMyPageTags', async () => { + pageTagsApi.listMyPageTags.mockResolvedValueOnce({ + items: [tagged({ tag: 'fight' })], + page: { limit: 100, offset: 0, total: 1 } + }); + render(PageTagsList, { + props: { + initialItems: [tagged({ tag: 'funny' })], + initialDistinct: [ + { tag: 'funny', count: 5 }, + { tag: 'fight', count: 2 } + ] + } + }); + await fireEvent.click(screen.getByTestId('library-page-tags-chip-fight')); + await tick(); + await Promise.resolve(); + await tick(); + expect(pageTagsApi.listMyPageTags).toHaveBeenCalledWith({ + tag: 'fight', + limit: 100 + }); + }); +}); diff --git a/frontend/src/lib/components/TaggedChapterRow.svelte b/frontend/src/lib/components/TaggedChapterRow.svelte new file mode 100644 index 0000000..21f99c1 --- /dev/null +++ b/frontend/src/lib/components/TaggedChapterRow.svelte @@ -0,0 +1,123 @@ + + +
  • + +
    + + {item.manga_title} · {chapterLabel({ + number: item.chapter_number, + title: item.chapter_title + })} + + + {item.match_count} + {item.match_count === 1 ? 'page' : 'pages'} + + {#if item.sample_storage_keys.length > 1} +
    + {#each item.sample_storage_keys.slice(1) as key (key)} + + {/each} +
    + {/if} +
    +
  • + + diff --git a/frontend/src/lib/components/TaggedChapterRow.svelte.test.ts b/frontend/src/lib/components/TaggedChapterRow.svelte.test.ts new file mode 100644 index 0000000..da50470 --- /dev/null +++ b/frontend/src/lib/components/TaggedChapterRow.svelte.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import TaggedChapterRow from './TaggedChapterRow.svelte'; + +afterEach(() => cleanup()); + +function fixture(overrides: Record = {}) { + return { + chapter_id: 'ch1', + manga_id: 'm1', + manga_title: 'Berserk', + chapter_number: 3, + chapter_title: null as string | null, + match_count: 12, + sample_storage_keys: [ + 'mangas/m1/chapters/ch1/pages/0001.png', + 'mangas/m1/chapters/ch1/pages/0002.png', + 'mangas/m1/chapters/ch1/pages/0003.png' + ], + ...overrides + }; +} + +describe('TaggedChapterRow', () => { + it('links the row to the reader at the chapter root (no ?page=)', () => { + const { container } = render(TaggedChapterRow, { + props: { item: fixture() } + }); + const link = container.querySelector('a.title') as HTMLAnchorElement; + expect(link.href).toContain('/manga/m1/chapter/ch1'); + expect(link.href).not.toContain('?page='); + }); + + it('shows pluralized match-count when > 1', () => { + render(TaggedChapterRow, { props: { item: fixture({ match_count: 12 }) } }); + expect(screen.getByText('12 pages')).toBeTruthy(); + }); + + it('shows singular match-count when exactly 1', () => { + render(TaggedChapterRow, { props: { item: fixture({ match_count: 1 }) } }); + expect(screen.getByText('1 page')).toBeTruthy(); + }); + + it('renders breadcrumb with chapter title when present', () => { + render(TaggedChapterRow, { + props: { item: fixture({ chapter_title: 'The Brand' }) } + }); + expect(screen.getByText(/Berserk · The Brand/)).toBeTruthy(); + }); + + it('renders thumbnail strip for samples beyond the primary cover', () => { + const { container } = render(TaggedChapterRow, { + props: { item: fixture() } + }); + // 3 samples: index 0 is the primary cover (rendered separately + // in the .cover slot). Indices 1-2 land in .samples. + const samples = container.querySelectorAll('.sample'); + expect(samples.length).toBe(2); + }); + + it('falls back to a placeholder when no sample keys', () => { + const { container } = render(TaggedChapterRow, { + props: { item: fixture({ sample_storage_keys: [] }) } + }); + expect(container.querySelector('.cover-placeholder')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/components/TaggedMangaRow.svelte b/frontend/src/lib/components/TaggedMangaRow.svelte new file mode 100644 index 0000000..fb44c16 --- /dev/null +++ b/frontend/src/lib/components/TaggedMangaRow.svelte @@ -0,0 +1,127 @@ + + +
  • + +
    + {item.manga_title} + + {item.match_count} + {item.match_count === 1 ? 'page' : 'pages'} + + {#if sampleStrip.length > 0} +
    + {#each sampleStrip as key (key)} + + {/each} +
    + {/if} +
    +
  • + + diff --git a/frontend/src/lib/components/TaggedMangaRow.svelte.test.ts b/frontend/src/lib/components/TaggedMangaRow.svelte.test.ts new file mode 100644 index 0000000..2db0374 --- /dev/null +++ b/frontend/src/lib/components/TaggedMangaRow.svelte.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import TaggedMangaRow from './TaggedMangaRow.svelte'; + +afterEach(() => cleanup()); + +function fixture(overrides: Record = {}) { + return { + manga_id: 'm1', + manga_title: 'Berserk', + manga_cover_image_path: 'mangas/m1/cover.png' as string | null, + match_count: 28, + sample_storage_keys: [ + 'mangas/m1/chapters/ch1/pages/0005.png', + 'mangas/m1/chapters/ch2/pages/0010.png', + 'mangas/m1/chapters/ch3/pages/0015.png' + ], + ...overrides + }; +} + +describe('TaggedMangaRow', () => { + it('links to the manga detail page', () => { + const { container } = render(TaggedMangaRow, { + props: { item: fixture() } + }); + const link = container.querySelector('a.title') as HTMLAnchorElement; + expect(link.href).toMatch(/\/manga\/m1$/); + }); + + it('prefers manga_cover_image_path as the primary cover', () => { + const { container } = render(TaggedMangaRow, { + props: { item: fixture() } + }); + const cover = container.querySelector('img.cover') as HTMLImageElement; + expect(cover.src).toContain('mangas/m1/cover.png'); + // All 3 samples land in the strip because the manga cover + // already filled the primary slot. + expect(container.querySelectorAll('.sample').length).toBe(3); + }); + + it('falls back to the first sample when no manga cover', () => { + const { container } = render(TaggedMangaRow, { + props: { item: fixture({ manga_cover_image_path: null }) } + }); + const cover = container.querySelector('img.cover') as HTMLImageElement; + expect(cover.src).toContain('chapters/ch1/pages/0005.png'); + // Strip shows the remaining 2 samples. + expect(container.querySelectorAll('.sample').length).toBe(2); + }); + + it('shows singular / plural match-count', () => { + const { rerender } = render(TaggedMangaRow, { + props: { item: fixture({ match_count: 1 }) } + }); + expect(screen.getByText('1 page')).toBeTruthy(); + rerender({ item: fixture({ match_count: 28 }) }); + expect(screen.getByText('28 pages')).toBeTruthy(); + }); + + it('renders placeholder when neither cover nor samples exist', () => { + const { container } = render(TaggedMangaRow, { + props: { + item: fixture({ + manga_cover_image_path: null, + sample_storage_keys: [] + }) + } + }); + expect(container.querySelector('.cover-placeholder')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/components/TaggedPageRow.svelte b/frontend/src/lib/components/TaggedPageRow.svelte new file mode 100644 index 0000000..01c0281 --- /dev/null +++ b/frontend/src/lib/components/TaggedPageRow.svelte @@ -0,0 +1,108 @@ + + +
  • + + +
  • + + diff --git a/frontend/src/lib/components/TaggedPageRow.svelte.test.ts b/frontend/src/lib/components/TaggedPageRow.svelte.test.ts new file mode 100644 index 0000000..2dd2bb7 --- /dev/null +++ b/frontend/src/lib/components/TaggedPageRow.svelte.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import TaggedPageRow from './TaggedPageRow.svelte'; + +afterEach(() => cleanup()); + +function fixture(overrides: Record = {}) { + return { + tag: 'funny', + page_id: 'p1', + chapter_id: 'ch1', + manga_id: 'm1', + page_number: 5, + chapter_number: 3, + chapter_title: null, + manga_title: 'Berserk', + storage_key: 'mangas/m1/chapters/ch1/pages/0005.png', + tagged_at: '2026-01-01T00:00:00Z', + ...overrides + }; +} + +describe('TaggedPageRow', () => { + it('links the breadcrumb to the reader at ?page=N', () => { + const { container } = render(TaggedPageRow, { props: { item: fixture() } }); + const anchors = Array.from( + container.querySelectorAll('a[href*="?page="]') + ) as HTMLAnchorElement[]; + // Both the (aria-hidden) cover link and the visible breadcrumb + // point to the reader at the same page. + expect(anchors.length).toBeGreaterThanOrEqual(2); + expect(anchors[0].href).toContain('/manga/m1/chapter/ch1?page=5'); + }); + + it('shows the tag pill by default', () => { + render(TaggedPageRow, { props: { item: fixture({ tag: 'fight' }) } }); + expect(screen.getByText('#fight')).toBeTruthy(); + }); + + it('omits the tag pill when showTagPill=false', () => { + render(TaggedPageRow, { + props: { item: fixture(), showTagPill: false } + }); + expect(screen.queryByText('#funny')).toBeNull(); + }); + + it('renders chapter title when present', () => { + render(TaggedPageRow, { + props: { item: fixture({ chapter_title: 'The Brand' }) } + }); + expect(screen.getByText(/The Brand/)).toBeTruthy(); + }); + + it('falls back to "Chapter N" when title is null', () => { + render(TaggedPageRow, { props: { item: fixture({ chapter_title: null }) } }); + expect(screen.getByText(/Chapter 3/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/components/TapZone.svelte b/frontend/src/lib/components/TapZone.svelte index f7e34a0..e188354 100644 --- a/frontend/src/lib/components/TapZone.svelte +++ b/frontend/src/lib/components/TapZone.svelte @@ -1,36 +1,153 @@
    diff --git a/frontend/src/lib/components/TapZone.svelte.test.ts b/frontend/src/lib/components/TapZone.svelte.test.ts index ea0faa8..d6bca0f 100644 --- a/frontend/src/lib/components/TapZone.svelte.test.ts +++ b/frontend/src/lib/components/TapZone.svelte.test.ts @@ -1,7 +1,48 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, cleanup } from '@testing-library/svelte'; import TapZone from './TapZone.svelte'; +// jsdom does not implement PointerEvent. Construct a MouseEvent of the +// correct type and decorate it with the `pointerType` + coords the +// component reads — Svelte forwards it to the onpointerdown handler +// unchanged, and the guard `e.pointerType === 'touch'` reads our +// property. +function pointerEvent( + type: string, + init: { pointerType?: string; clientX?: number; clientY?: number } = {} +): Event { + const ev = new MouseEvent(type, { + bubbles: true, + clientX: init.clientX, + clientY: init.clientY + }); + if (init.pointerType != null) { + Object.defineProperty(ev, 'pointerType', { + value: init.pointerType, + configurable: true + }); + } + return ev; +} + +function pointerDown( + el: Element, + init: { pointerType: string; clientX: number; clientY: number } +) { + el.dispatchEvent(pointerEvent('pointerdown', init)); +} + +function pointerMove( + el: Element, + init: { clientX: number; clientY: number } +) { + el.dispatchEvent(pointerEvent('pointermove', init)); +} + +function pointerUp(el: Element) { + el.dispatchEvent(pointerEvent('pointerup')); +} + afterEach(() => cleanup()); describe('TapZone', () => { @@ -49,4 +90,147 @@ describe('TapZone', () => { expect(screen.getByTestId('reader-tap-center')).toBeTruthy(); expect(screen.getByTestId('reader-tap-right')).toBeTruthy(); }); + + describe('long-press', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('fires onLongPress with anchor coords after 450ms touch hold', () => { + const onLongPress = vi.fn(); + const onNext = vi.fn(); + render(TapZone, { + props: { + onPrev: () => {}, + onNext, + onToggle: () => {}, + onLongPress + } + }); + const right = screen.getByTestId('tap-zone-right'); + pointerDown(right, { + pointerType: 'touch', + clientX: 200, + clientY: 300 + }); + vi.advanceTimersByTime(500); + expect(onLongPress).toHaveBeenCalledWith({ x: 200, y: 300 }); + // The synthesized click that follows the long-press is + // suppressed so the next-page handler doesn't fire. + right.click(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('does not fire onLongPress for mouse pointers', () => { + const onLongPress = vi.fn(); + render(TapZone, { + props: { + onPrev: () => {}, + onNext: () => {}, + onToggle: () => {}, + onLongPress + } + }); + const center = screen.getByTestId('tap-zone-center'); + pointerDown(center, { + pointerType: 'mouse', + clientX: 100, + clientY: 100 + }); + vi.advanceTimersByTime(1000); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it('cancels long-press when pointer moves beyond tolerance', () => { + const onLongPress = vi.fn(); + render(TapZone, { + props: { + onPrev: () => {}, + onNext: () => {}, + onToggle: () => {}, + onLongPress + } + }); + const right = screen.getByTestId('tap-zone-right'); + pointerDown(right, { + pointerType: 'touch', + clientX: 100, + clientY: 100 + }); + pointerMove(right, { clientX: 200, clientY: 100 }); + vi.advanceTimersByTime(500); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it('cancels long-press on pointer up before threshold', () => { + const onLongPress = vi.fn(); + render(TapZone, { + props: { + onPrev: () => {}, + onNext: () => {}, + onToggle: () => {}, + onLongPress + } + }); + const center = screen.getByTestId('tap-zone-center'); + pointerDown(center, { + pointerType: 'touch', + clientX: 100, + clientY: 100 + }); + pointerUp(center); + vi.advanceTimersByTime(500); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it('regular click still fires onNext when no long-press handler', () => { + const onNext = vi.fn(); + render(TapZone, { + props: { + onPrev: () => {}, + onNext, + onToggle: () => {} + } + }); + screen.getByTestId('tap-zone-right').click(); + expect(onNext).toHaveBeenCalledOnce(); + }); + + it('suppression auto-expires so the next legitimate tap fires after a slide-off long-press', () => { + // Scenario: user long-presses, finger slides off the zone + // onto the opened sheet, releases there. No click is + // synthesized on the zone, so without the expiry the + // suppression would leak and eat the next tap. The 500ms + // expiry inside the long-press callback caps the + // suppression window. + const onLongPress = vi.fn(); + const onNext = vi.fn(); + render(TapZone, { + props: { + onPrev: () => {}, + onNext, + onToggle: () => {}, + onLongPress + } + }); + const right = screen.getByTestId('tap-zone-right'); + pointerDown(right, { + pointerType: 'touch', + clientX: 100, + clientY: 100 + }); + // Long-press fires. + vi.advanceTimersByTime(450); + expect(onLongPress).toHaveBeenCalledOnce(); + // Drain the suppression-expiry window without a click — + // simulates the user sliding off and releasing elsewhere. + vi.advanceTimersByTime(600); + // Fresh tap on the zone — must NOT be eaten. + right.click(); + expect(onNext).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 384ea48..27294c0 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -18,6 +18,7 @@ import Search from '@lucide/svelte/icons/search'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down'; + import ArrowRight from '@lucide/svelte/icons/arrow-right'; import Plus from '@lucide/svelte/icons/plus'; const PAGE_SIZE = 50; @@ -402,7 +403,22 @@ {/if} {/snippet} -

    Mangas

    +
    +

    Mangas

    + + + Page search + +
    + .heading-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + } + + .heading-row h1 { + margin-bottom: 0; + } + + .alt-search { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text-muted); + font-size: var(--font-sm); + text-decoration: none; + white-space: nowrap; + } + + .alt-search:hover { + color: var(--primary); + } + .controls { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-4); + margin-top: var(--space-3); } .search-row { diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 386e93f..4993660 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -5,7 +5,12 @@ removeMangaFromCollection, updateCollection } from '$lib/api/collections'; + import { + removePageFromCollection, + type CollectionPageItem + } from '$lib/api/page_collections'; import type { Manga } from '$lib/api/client'; + import { fileUrl } 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'; @@ -18,6 +23,8 @@ let collection = $state({ ...data.collection }); // svelte-ignore state_referenced_locally let mangas = $state([...data.mangas]); + // svelte-ignore state_referenced_locally + let pages = $state([...data.pages]); let editing = $state(false); let editName = $state(''); @@ -72,6 +79,17 @@ editError = (e as Error).message; } } + + async function onRemovePage(p: CollectionPageItem) { + const snapshot = pages; + pages = pages.filter((x) => x.page_id !== p.page_id); + try { + await removePageFromCollection(collection.id, p.page_id); + } catch (e) { + pages = snapshot; + editError = (e as Error).message; + } + } @@ -162,28 +180,73 @@ {/if} -{#if mangas.length === 0} +{#if mangas.length === 0 && pages.length === 0}

    This collection is empty.

    -{:else} -
      - {#each mangas as m (m.id)} -
    • - - -
    • - {/each} -
    +{/if} + +{#if mangas.length > 0} +
    +

    Mangas

    +
      + {#each mangas as m (m.id)} +
    • + + +
    • + {/each} +
    +
    +{/if} + +{#if pages.length > 0} +
    +

    Pages

    + +
    {/if} diff --git a/frontend/src/routes/search/+page.ts b/frontend/src/routes/search/+page.ts new file mode 100644 index 0000000..e2948d2 --- /dev/null +++ b/frontend/src/routes/search/+page.ts @@ -0,0 +1,92 @@ +import { ApiError } from '$lib/api/client'; +import { + listMyDistinctPageTags, + listMyPageTags, + listTaggedChapters, + listTaggedMangas, + type PageTagSummary, + type TaggedChapterAggregate, + type TaggedMangaAggregate, + type TaggedPageItem +} from '$lib/api/page_tags'; +import type { PageLoad } from './$types'; + +export const ssr = false; + +type View = 'pages' | 'chapters' | 'mangas'; +type Order = 'desc' | 'asc'; + +/** + * Loader for the /search page. URL is the source of truth — refresh + * or share a link lands the user on the same view. + * + * - `?tag=` exact-match tag filter. Empty → no results, just the + * chip cloud for browsing. + * - `?view=pages|chapters|mangas` — defaults to `pages` when omitted. + * - `?order=desc|asc` — only meaningful for chapters/mangas tabs. + * `desc` (most matches first) is the default. + * + * `?text=` is reserved for the planned OCR text-search input. The + * backend rejects it with 501 + stable code + * `text_search_not_yet_supported` today; the frontend never sets it. + */ +export const load: PageLoad = async ({ url }) => { + const tag = url.searchParams.get('tag'); + const viewParam = url.searchParams.get('view'); + const view: View = + viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages'; + const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; + + const empty = { + authenticated: true, + tag, + view, + order, + distinct: [] as PageTagSummary[], + pages: [] as TaggedPageItem[], + chapters: [] as TaggedChapterAggregate[], + mangas: [] as TaggedMangaAggregate[], + total: 0, + error: null as string | null + }; + + try { + const distinct = await listMyDistinctPageTags(undefined, 100); + // No tag selected → just the chip cloud. + if (!tag) return { ...empty, distinct }; + + if (view === 'chapters') { + const r = await listTaggedChapters({ tag, order, limit: 100 }); + return { + ...empty, + distinct, + chapters: r.items, + total: r.page.total ?? 0 + }; + } + if (view === 'mangas') { + const r = await listTaggedMangas({ tag, order, limit: 100 }); + return { + ...empty, + distinct, + mangas: r.items, + total: r.page.total ?? 0 + }; + } + const r = await listMyPageTags({ tag, limit: 100 }); + return { + ...empty, + distinct, + pages: r.items, + total: r.page.total ?? 0 + }; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { ...empty, authenticated: false }; + } + if (e instanceof ApiError) { + return { ...empty, error: e.message }; + } + throw e; + } +};