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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 15:51:38 +02:00
parent 9910a0a995
commit 6c901e64c9
50 changed files with 6971 additions and 132 deletions

View File

@@ -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. 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. - **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. - **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. - **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. - **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.

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.60.2" version = "0.62.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.60.2" version = "0.62.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -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);

View File

@@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse;
use crate::app::AppState; use crate::app::AppState;
use crate::auth::extractor::CurrentUser; use crate::auth::extractor::CurrentUser;
use crate::domain::collection::{ use crate::domain::collection::{
Collection, CollectionPatch, CollectionSummary, NewCollection, Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
}; };
use crate::domain::manga::Manga; use crate::domain::manga::Manga;
use crate::domain::patch::Patch; use crate::domain::patch::Patch;
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
"/collections/:id/mangas/:manga_id", "/collections/:id/mangas/:manga_id",
delete(remove_manga), delete(remove_manga),
) )
.route("/collections/:id/pages", get(list_pages).post(add_page))
.route("/collections/:id/pages/:page_id", delete(remove_page))
.route( .route(
"/mangas/:id/my-collections", "/mangas/:id/my-collections",
get(list_my_collections_containing), get(list_my_collections_containing),
) )
.route(
"/pages/:id/my-collections",
get(list_my_collections_containing_page),
)
} }
const MAX_NAME_LEN: usize = 64; const MAX_NAME_LEN: usize = 64;
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
pub manga_id: Uuid, pub manga_id: Uuid,
} }
#[derive(Debug, Deserialize)]
pub struct AddPageBody {
pub page_id: Uuid,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct MangaCollectionIds { pub struct MangaCollectionIds {
pub collection_ids: Vec<Uuid>, pub collection_ids: Vec<Uuid>,
} }
#[derive(Debug, Serialize)]
pub struct PageCollectionIds {
pub collection_ids: Vec<Uuid>,
}
fn validate_name(name: &str) -> AppResult<()> { fn validate_name(name: &str) -> AppResult<()> {
let trimmed = name.trim(); let trimmed = name.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
Ok(Json(MangaCollectionIds { collection_ids: ids })) Ok(Json(MangaCollectionIds { collection_ids: ids }))
} }
async fn list_pages(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<CollectionPageItem>>> {
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<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Json(body): Json<AddPageBody>,
) -> AppResult<StatusCode> {
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<AppState>,
CurrentUser(user): CurrentUser,
Path((collection_id, page_id)): Path<(Uuid, Uuid)>,
) -> AppResult<StatusCode> {
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<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<PageCollectionIds>> {
// 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 /// Returns the row iff the caller owns it. Both "doesn't exist" and
/// "exists but belongs to someone else" surface as `NotFound` so the /// "exists but belongs to someone else" surface as `NotFound` so the
/// API doesn't disclose collection existence to non-owners — the /// API doesn't disclose collection existence to non-owners — the

View File

@@ -9,6 +9,7 @@ pub mod genres;
pub mod health; pub mod health;
pub mod history; pub mod history;
pub mod mangas; pub mod mangas;
pub mod page_tags;
pub mod pagination; pub mod pagination;
pub mod tags; pub mod tags;
@@ -28,6 +29,7 @@ pub fn routes() -> Router<AppState> {
.merge(tags::routes()) .merge(tags::routes())
.merge(authors::routes()) .merge(authors::routes())
.merge(collections::routes()) .merge(collections::routes())
.merge(page_tags::routes())
.merge(history::routes()) .merge(history::routes())
.merge(admin::routes()) .merge(admin::routes())
} }

View File

@@ -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<AppState> {
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<String>,
/// Prefix filter (autocomplete-style).
#[serde(default)]
pub q: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DistinctParams {
#[serde(default)]
pub q: Option<String>,
#[serde(default = "default_distinct_limit")]
pub limit: i64,
}
#[derive(Debug, Serialize)]
struct TagsResponse {
tags: Vec<String>,
}
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<String> {
// 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::<Vec<_>>()
.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<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
Json(input): Json<NewPageTag>,
) -> AppResult<StatusCode> {
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<AppState>,
CurrentUser(user): CurrentUser,
Path((page_id, tag)): Path<(Uuid, String)>,
) -> AppResult<StatusCode> {
// 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<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<TagsResponse>> {
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<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListMineParams>,
) -> AppResult<Json<PagedResponse<TaggedPageItem>>> {
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<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<DistinctParams>,
) -> AppResult<Json<DistinctResponse>> {
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<PageTagSummary>,
}
#[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<String>,
#[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<String>,
}
fn parse_order(raw: Option<&str>) -> AppResult<Order> {
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<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
ensure_text_unsupported(params.text.as_deref())?;
let tag = normalize_tag(&params.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<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
ensure_text_unsupported(params.text.as_deref())?;
let tag = normalize_tag(&params.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());
}
}

View File

@@ -33,6 +33,22 @@ pub struct CollectionSummary {
pub sample_covers: Vec<String>, pub sample_covers: Vec<String>,
} }
/// 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<String>,
pub manga_title: String,
pub storage_key: String,
pub added_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct NewCollection { pub struct NewCollection {
pub name: String, pub name: String,

View File

@@ -7,6 +7,7 @@ pub mod collection;
pub mod genre; pub mod genre;
pub mod manga; pub mod manga;
pub mod page; pub mod page;
pub mod page_tag;
pub mod patch; pub mod patch;
pub mod read_progress; pub mod read_progress;
pub mod session; pub mod session;
@@ -21,10 +22,14 @@ pub use api_token::ApiToken;
pub use author::{Author, AuthorRef, AuthorWithCount}; pub use author::{Author, AuthorRef, AuthorWithCount};
pub use bookmark::{Bookmark, BookmarkSummary}; pub use bookmark::{Bookmark, BookmarkSummary};
pub use chapter::Chapter; pub use chapter::Chapter;
pub use collection::{Collection, CollectionSummary}; pub use collection::{Collection, CollectionPageItem, CollectionSummary};
pub use genre::{Genre, GenreRef}; pub use genre::{Genre, GenreRef};
pub use manga::{Manga, MangaCard, MangaDetail}; pub use manga::{Manga, MangaCard, MangaDetail};
pub use page::Page; pub use page::Page;
pub use page_tag::{
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
TaggedPageItem,
};
pub use patch::Patch; pub use patch::Patch;
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary}; pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
pub use session::Session; pub use session::Session;

View File

@@ -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<String>,
pub manga_title: String,
pub storage_key: String,
pub tagged_at: DateTime<Utc>,
}
/// 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<String>,
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<String>,
}
/// 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<String>,
pub match_count: i64,
pub sample_storage_keys: Vec<String>,
}

View File

@@ -38,6 +38,16 @@ pub enum AppError {
message: String, message: String,
details: serde_json::Value, 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)] #[error(transparent)]
Database(#[from] sqlx::Error), Database(#[from] sqlx::Error),
#[error(transparent)] #[error(transparent)]
@@ -64,6 +74,7 @@ impl AppError {
AppError::ServiceUnavailable(_) => "service_unavailable", AppError::ServiceUnavailable(_) => "service_unavailable",
AppError::TooManyRequests { .. } => "too_many_requests", AppError::TooManyRequests { .. } => "too_many_requests",
AppError::ValidationFailed { .. } => "validation_failed", AppError::ValidationFailed { .. } => "validation_failed",
AppError::NotImplemented { code, .. } => code,
AppError::Database(sqlx::Error::RowNotFound) => "not_found", AppError::Database(sqlx::Error::RowNotFound) => "not_found",
AppError::Database(_) => "internal_error", AppError::Database(_) => "internal_error",
AppError::Storage(StorageError::NotFound) => "not_found", AppError::Storage(StorageError::NotFound) => "not_found",
@@ -124,6 +135,11 @@ impl IntoResponse for AppError {
message.clone(), message.clone(),
Some(details.clone()), Some(details.clone()),
), ),
AppError::NotImplemented { message, .. } => (
StatusCode::NOT_IMPLEMENTED,
(*message).to_string(),
None,
),
AppError::Database(sqlx::Error::RowNotFound) => { AppError::Database(sqlx::Error::RowNotFound) => {
(StatusCode::NOT_FOUND, "not found".to_string(), None) (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::Storage(StorageError::NotFound).code(), "not_found");
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found"); assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error"); 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"
);
} }
} }

View File

@@ -7,7 +7,7 @@
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::domain::collection::{Collection, CollectionSummary}; use crate::domain::collection::{Collection, CollectionPageItem, CollectionSummary};
use crate::domain::manga::Manga; use crate::domain::manga::Manga;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
@@ -278,3 +278,132 @@ pub async fn list_collections_containing(
.await?; .await?;
Ok(rows.into_iter().map(|(id,)| id).collect()) 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<bool> {
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<CollectionPageItem>, 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<Vec<Uuid>> {
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())
}

View File

@@ -9,6 +9,7 @@ pub mod crawler;
pub mod genre; pub mod genre;
pub mod manga; pub mod manga;
pub mod page; pub mod page;
pub mod page_tag;
pub mod read_progress; pub mod read_progress;
pub mod session; pub mod session;
pub mod tag; pub mod tag;

View File

@@ -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<bool> {
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<Vec<String>> {
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<TaggedPageItem>, 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<Vec<PageTagSummary>> {
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<TaggedChapterAggregate>, 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<TaggedMangaAggregate>, 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))
}

View File

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

View File

@@ -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<String>) {
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<String> = 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);
}

View File

@@ -53,7 +53,13 @@ async function mockApis(page: Page) {
); );
// Catalog returns a single card whose href points at MID so the // Catalog returns a single card whose href points at MID so the
// SPA-click walk lands on the manga we've mocked downstream. // 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({ r.fulfill({
status: 200, status: 200,
contentType: 'application/json', 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 === '/'); 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);
});
}); });

View File

@@ -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();
});
});

View File

@@ -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'
);
});
});

251
frontend/e2e/search.spec.ts Normal file
View File

@@ -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}`
);
});
});

View File

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

View File

@@ -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<string, unknown> = {}) {
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<typeof globalThis.fetch>;
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$/);
});
});

View File

@@ -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<CollectionPagesPage> {
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<CollectionPagesPage>(
`/v1/collections/${encodeURIComponent(collectionId)}/pages${qs ? `?${qs}` : ''}`
);
}
export async function addPageToCollection(
collectionId: string,
pageId: string
): Promise<void> {
await request<void>(
`/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<void> {
await request<void>(
`/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<string[]> {
const r = await request<{ collection_ids: string[] }>(
`/v1/pages/${encodeURIComponent(pageId)}/my-collections`
);
return r.collection_ids;
}

View File

@@ -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<string, unknown> = {}) {
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<typeof globalThis.fetch>;
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$/);
});
});

View File

@@ -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<string[]> {
const r = await request<{ tags: string[] }>(
`/v1/pages/${encodeURIComponent(pageId)}/my-tags`
);
return r.tags;
}
export async function addTagToPage(pageId: string, tag: string): Promise<void> {
await request<void>(`/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<void> {
await request<void>(
`/v1/pages/${encodeURIComponent(pageId)}/tags/${encodeURIComponent(tag)}`,
{ method: 'DELETE' }
);
}
export async function listMyPageTags(
opts: ListMineOptions = {}
): Promise<MyPageTagsPage> {
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<MyPageTagsPage>(`/v1/me/page-tags${qs ? `?${qs}` : ''}`);
}
export async function listMyDistinctPageTags(
prefix?: string,
limit?: number
): Promise<PageTagSummary[]> {
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<TaggedChaptersPage> {
return request<TaggedChaptersPage>(
`/v1/me/page-tags/chapters?${aggregateQs(opts)}`
);
}
export async function listTaggedMangas(
opts: AggregateOptions
): Promise<TaggedMangasPage> {
return request<TaggedMangasPage>(
`/v1/me/page-tags/mangas?${aggregateQs(opts)}`
);
}

View File

@@ -0,0 +1,315 @@
<script lang="ts">
import {
getMyTagsForPage,
addTagToPage,
removeTagFromPage,
listMyDistinctPageTags,
type PageTagSummary
} from '$lib/api/page_tags';
import X from '@lucide/svelte/icons/x';
/**
* Panel content for editing a user's tags on a single page. Slots
* into either a `Sheet` (mobile) or a `Modal` (desktop) — both
* provide the chrome (title bar, dismiss, focus trap), this
* component owns only the chip editor + autocomplete.
*/
let {
pageId,
onChange
}: {
pageId: string;
/** Fires after every successful add/remove so the host can
* refresh the context menu's "Tagged: …" line. */
onChange?: (tags: string[]) => void;
} = $props();
let tags = $state<string[]>([]);
let suggestions = $state<PageTagSummary[]>([]);
let draft = $state('');
let loading = $state(false);
let busy = $state(false);
let error: string | null = $state(null);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
// Re-load when the host swaps in a different page.
void pageId;
void load();
});
async function load() {
loading = true;
error = null;
try {
const [current, dist] = await Promise.all([
getMyTagsForPage(pageId),
listMyDistinctPageTags(undefined, 12)
]);
tags = current;
suggestions = dist;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
async function refreshSuggestions() {
try {
const next = await listMyDistinctPageTags(
draft.trim() || undefined,
12
);
suggestions = next ?? [];
} catch {
// Soft-fail — autocomplete is non-critical.
}
}
async function add(rawTag: string) {
const tag = rawTag.trim();
if (!tag || busy) return;
busy = true;
error = null;
try {
await addTagToPage(pageId, tag);
// Re-read from the server so the chip shows the
// normalized form (lowercase, whitespace-collapsed) rather
// than whatever the user typed.
tags = await getMyTagsForPage(pageId);
draft = '';
onChange?.(tags);
void refreshSuggestions();
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
// The input is `disabled={busy}` during the request, which
// makes browsers drop focus. Restore it so the user can
// immediately type the next tag in a "tag, tag, tag" flow
// without clicking back into the field.
queueMicrotask(() => inputEl?.focus());
}
}
async function remove(tag: string) {
if (busy) return;
busy = true;
error = null;
// Optimistic — slice it out, revert on failure.
const previous = tags;
tags = tags.filter((t) => t !== tag);
try {
await removeTagFromPage(pageId, tag);
onChange?.(tags);
} catch (e) {
tags = previous;
error = (e as Error).message;
} finally {
busy = false;
}
}
function onSubmit(e: SubmitEvent) {
e.preventDefault();
void add(draft);
}
function onInput(e: Event) {
draft = (e.target as HTMLInputElement).value;
void refreshSuggestions();
}
function onKeydown(e: KeyboardEvent) {
// Enter is handled by the form submit, but support comma as a
// chip separator since users type tag lists.
if (e.key === ',') {
e.preventDefault();
void add(draft);
}
}
// Suggestions minus already-present tags, since adding a present
// tag is a no-op and clutters the row.
const filteredSuggestions = $derived(
suggestions.filter((s) => !tags.includes(s.tag))
);
</script>
<div class="panel" data-testid="add-tags-panel">
{#if loading}
<p class="status">Loading tags…</p>
{:else}
<div class="chips" data-testid="add-tags-chips">
{#each tags as t (t)}
<span class="chip">
<span>{t}</span>
<button
type="button"
class="chip-x"
aria-label={`Remove ${t}`}
disabled={busy}
onclick={() => remove(t)}
data-testid={`add-tags-remove-${t}`}
>
<X size={12} aria-hidden="true" />
</button>
</span>
{/each}
{#if tags.length === 0}
<span class="hint">No tags yet — add one below.</span>
{/if}
</div>
{/if}
<form class="input-row" onsubmit={onSubmit} action="javascript:void(0)">
<input
bind:this={inputEl}
type="text"
value={draft}
oninput={onInput}
onkeydown={onKeydown}
placeholder="Type a tag and press Enter"
aria-label="New tag"
maxlength="64"
disabled={busy}
data-testid="add-tags-input"
/>
<button
type="submit"
class="add-btn"
disabled={!draft.trim() || busy}
data-testid="add-tags-submit"
>
Add
</button>
</form>
{#if filteredSuggestions.length > 0}
<div class="suggestions" data-testid="add-tags-suggestions">
<p class="suggestions-label">Suggestions</p>
<div class="suggestion-chips">
{#each filteredSuggestions as s (s.tag)}
<button
type="button"
class="suggestion-chip"
disabled={busy}
onclick={() => add(s.tag)}
data-testid={`add-tags-suggest-${s.tag}`}
>
+ {s.tag}
</button>
{/each}
</div>
</div>
{/if}
{#if error}
<p class="error" role="alert" data-testid="add-tags-error">{error}</p>
{/if}
</div>
<style>
.panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.status,
.hint {
color: var(--text-muted);
margin: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
min-height: 32px;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary-soft-bg);
color: var(--text);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
font-size: var(--font-sm);
}
.chip-x {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text-muted);
border: 0;
padding: 0;
cursor: pointer;
line-height: 0;
}
.chip-x:hover:not(:disabled) {
color: var(--text);
}
.input-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.input-row input {
flex: 1;
min-width: 0;
}
.add-btn {
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 36px;
}
.add-btn:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.suggestions-label {
margin: 0 0 var(--space-1);
color: var(--text-muted);
font-size: var(--font-xs);
}
.suggestion-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.suggestion-chip {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
font-size: var(--font-sm);
cursor: pointer;
}
.suggestion-chip:hover:not(:disabled) {
background: var(--surface-elevated);
}
.error {
color: var(--danger);
margin: 0;
}
</style>

View File

@@ -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']);
});
});

View File

@@ -8,15 +8,32 @@
removeMangaFromCollection, removeMangaFromCollection,
type CollectionSummary type CollectionSummary
} from '$lib/api/collections'; } from '$lib/api/collections';
import {
addPageToCollection,
getMyCollectionsContainingPage,
removePageFromCollection
} from '$lib/api/page_collections';
import Plus from '@lucide/svelte/icons/plus'; 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 { let {
open, open,
mangaId, target,
onClose onClose
}: { }: {
open: boolean; open: boolean;
mangaId: string; target: Target;
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
@@ -28,23 +45,42 @@
let loading = $state(false); let loading = $state(false);
let error: string | null = $state(null); let error: string | null = $state(null);
// Refetch every time the modal opens (and when the manga id changes // Refetch on open and whenever the target changes (e.g., the user
// mid-session — unlikely but cheap). The data is per-user and per- // right-clicks a different page while the modal is in flight). The
// manga, so re-fetching is the simplest way to stay in sync with // dependency on `target.id` is explicit so the $effect re-runs.
// changes made elsewhere (e.g., a collection deleted on another page).
$effect(() => { $effect(() => {
if (open) { if (open) {
// Touching target.id wires the reactive dependency.
void target.id;
void load(); void load();
} }
}); });
async function loadContaining(t: Target): Promise<string[]> {
return t.kind === 'manga'
? getMyCollectionsContaining(t.id)
: getMyCollectionsContainingPage(t.id);
}
async function addTo(collectionId: string, t: Target): Promise<void> {
return t.kind === 'manga'
? addMangaToCollection(collectionId, t.id)
: addPageToCollection(collectionId, t.id);
}
async function removeFrom(collectionId: string, t: Target): Promise<void> {
return t.kind === 'manga'
? removeMangaFromCollection(collectionId, t.id)
: removePageFromCollection(collectionId, t.id);
}
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
const [page, ids] = await Promise.all([ const [page, ids] = await Promise.all([
listMyCollections({ limit: 200 }), listMyCollections({ limit: 200 }),
getMyCollectionsContaining(mangaId) loadContaining(target)
]); ]);
collections = page.items; collections = page.items;
containingIds = new Set(ids); 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<T>(s: Set<T>, v: T): Set<T> { function withAdd<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s); const n = new Set(s);
n.add(v); n.add(v);
@@ -72,21 +105,27 @@
async function toggle(collection: CollectionSummary) { async function toggle(collection: CollectionSummary) {
if (busyIds.has(collection.id)) return; if (busyIds.has(collection.id)) return;
const wasIn = containingIds.has(collection.id); const wasIn = containingIds.has(collection.id);
// Optimistic toggle — local set first; revert on failure.
containingIds = wasIn containingIds = wasIn
? withDelete(containingIds, collection.id) ? withDelete(containingIds, collection.id)
: withAdd(containingIds, collection.id); : withAdd(containingIds, collection.id);
busyIds = withAdd(busyIds, collection.id); busyIds = withAdd(busyIds, collection.id);
try { try {
if (wasIn) { if (wasIn) {
await removeMangaFromCollection(collection.id, mangaId); await removeFrom(collection.id, target);
collection.manga_count = Math.max(0, collection.manga_count - 1); // 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 { } else {
await addMangaToCollection(collection.id, mangaId); await addTo(collection.id, target);
collection.manga_count += 1; if (target.kind === 'manga') {
collection.manga_count += 1;
}
} }
} catch (e) { } catch (e) {
// Revert (read latest containingIds, not the pre-toggle snapshot).
containingIds = wasIn containingIds = wasIn
? withAdd(containingIds, collection.id) ? withAdd(containingIds, collection.id)
: withDelete(containingIds, collection.id); : withDelete(containingIds, collection.id);
@@ -103,15 +142,11 @@
error = null; error = null;
try { try {
const created = await createCollection({ name }); const created = await createCollection({ name });
// The list endpoint sorts by updated_at DESC; adding the await addTo(created.id, target);
// manga immediately also bumps it. Append a synthetic
// summary so the new collection appears checked-on right
// away rather than waiting for a refetch.
await addMangaToCollection(created.id, mangaId);
collections = [ collections = [
{ {
...created, ...created,
manga_count: 1, manga_count: target.kind === 'manga' ? 1 : 0,
sample_covers: [] sample_covers: []
}, },
...collections ...collections
@@ -156,10 +191,20 @@
/> />
<span class="row-label"> <span class="row-label">
<span class="row-name">{c.name}</span> <span class="row-name">{c.name}</span>
<span class="row-count"> {#if target.kind === 'manga'}
{c.manga_count} <!-- The count is the manga count only.
{c.manga_count === 1 ? 'manga' : 'mangas'} Suppress it on page targets to
</span> avoid the rot of showing "0
mangas" beside a collection that
has 12 saved pages — the user
would read the modal as broken.
A backend page_count is a
follow-up. -->
<span class="row-count">
{c.manga_count}
{c.manga_count === 1 ? 'manga' : 'mangas'}
</span>
{/if}
</span> </span>
</label> </label>
</li> </li>

View File

@@ -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();
});
});

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import FolderPlus from '@lucide/svelte/icons/folder-plus';
import Tag from '@lucide/svelte/icons/tag';
import Download from '@lucide/svelte/icons/download';
import Link2 from '@lucide/svelte/icons/link-2';
/**
* Floating context menu anchored at `(anchor.x, anchor.y)` in
* viewport coordinates. After mount the menu position is clamped
* so it doesn't overflow the viewport on the right or bottom edge.
*
* Closing rules:
* - Esc.
* - pointerdown outside the menu (capture-phase so we beat the
* underlying right-click handler on the next page image).
* - Any scroll or resize, since the anchor is stale.
*/
let {
open,
anchor,
onClose,
onAddToCollection,
onAddTag,
onSaveImage,
onCopyLink,
collectionsCount,
tags
}: {
open: boolean;
anchor: { x: number; y: number };
onClose: () => void;
onAddToCollection: () => void;
onAddTag: () => void;
/** Open the page image in a new tab so the browser's "Save as"
* is one click away. */
onSaveImage: () => void;
/** Copy the canonical reader URL (with `?page=N`) to clipboard. */
onCopyLink: () => void;
/** Null while the count is still loading. */
collectionsCount: number | null;
/** May be empty. */
tags: string[];
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
let pos = $state<{ left: number; top: number }>({ left: 0, top: 0 });
$effect(() => {
if (open) {
// Seed at the anchor, then post-mount nudge into the
// viewport once we know the menu's measured size.
pos = { left: anchor.x, top: anchor.y };
void clampAfterMount();
}
});
async function clampAfterMount() {
await tick();
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = anchor.x;
let top = anchor.y;
if (left + rect.width + 8 > vw) left = Math.max(8, vw - rect.width - 8);
if (top + rect.height + 8 > vh) top = Math.max(8, vh - rect.height - 8);
pos = { left, top };
}
function onDocPointerDown(e: PointerEvent) {
if (!open) return;
if (menuEl && !menuEl.contains(e.target as Node)) {
onClose();
}
}
function onDocKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}
function onScrollOrResize() {
if (open) onClose();
}
onMount(() => {
document.addEventListener('pointerdown', onDocPointerDown, true);
document.addEventListener('keydown', onDocKeydown, true);
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
document.removeEventListener('pointerdown', onDocPointerDown, true);
document.removeEventListener('keydown', onDocKeydown, true);
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
});
const collectionsLine = $derived(
collectionsCount == null
? 'Loading…'
: collectionsCount === 0
? 'Not in any collection'
: `In ${collectionsCount} collection${collectionsCount === 1 ? '' : 's'}`
);
const tagsLine = $derived(
tags.length === 0 ? 'No tags yet' : `Tagged: ${tags.join(', ')}`
);
</script>
{#if open}
<!--
Rendered as a popover-shaped div + plain <button> children
rather than role="menu" + role="menuitem". The ARIA menu
pattern expects arrow-key navigation between menuitems, and
implementing that here would either re-invent a small focus
manager or violate the contract. `role="group"` is the
minimal role that gives `aria-label` a mooring — without it,
the label is ignored and a screen-reader user landing on the
first <button> would have no announced container.
-->
<div
bind:this={menuEl}
class="menu"
role="group"
aria-label="Page actions"
style:left="{pos.left}px"
style:top="{pos.top}px"
data-testid="page-context-menu"
>
<button
type="button"
class="item"
onclick={onAddToCollection}
data-testid="page-context-add-to-collection"
>
<FolderPlus size={14} aria-hidden="true" />
<span>Add to collection…</span>
</button>
<button
type="button"
class="item"
onclick={onAddTag}
data-testid="page-context-add-tag"
>
<Tag size={14} aria-hidden="true" />
<span>Add tag…</span>
</button>
<div class="divider" aria-hidden="true"></div>
<button
type="button"
class="item"
onclick={onSaveImage}
data-testid="page-context-save-image"
>
<Download size={14} aria-hidden="true" />
<span>Save image</span>
</button>
<button
type="button"
class="item"
onclick={onCopyLink}
data-testid="page-context-copy-link"
>
<Link2 size={14} aria-hidden="true" />
<span>Copy page link</span>
</button>
<div class="divider" aria-hidden="true"></div>
<p class="hint" data-testid="page-context-collections-line">
{collectionsLine}
</p>
<p class="hint" data-testid="page-context-tags-line">{tagsLine}</p>
</div>
{/if}
<style>
.menu {
position: fixed;
z-index: var(--z-modal);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--space-1);
min-width: 14rem;
max-width: min(20rem, calc(100vw - 16px));
display: flex;
flex-direction: column;
gap: 2px;
}
.item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: var(--radius-sm);
text-align: left;
cursor: pointer;
}
.item:hover,
.item:focus-visible {
background: var(--surface-elevated);
}
.divider {
height: 1px;
background: var(--border);
margin: var(--space-1) 0;
}
.hint {
margin: 0;
padding: 0 var(--space-2);
color: var(--text-muted);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -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();
});
});

View File

@@ -0,0 +1,144 @@
<script lang="ts">
import {
listMyPageTags,
type TaggedPageItem,
type PageTagSummary
} from '$lib/api/page_tags';
import TaggedPageRow from './TaggedPageRow.svelte';
/**
* Library tab content for "Page tags". Renders the distinct-tags
* chip cloud (seeded from the loader) and a paged list of tagged
* pages, with breadcrumb + thumbnail. Clicking a chip filters the
* list to that tag; clicking a row jumps to the reader.
*/
let {
initialItems,
initialDistinct
}: {
initialItems: TaggedPageItem[];
initialDistinct: PageTagSummary[];
} = $props();
// svelte-ignore state_referenced_locally
let items = $state<TaggedPageItem[]>([...initialItems]);
let active: string | null = $state(null);
let loading = $state(false);
let error: string | null = $state(null);
async function setActive(tag: string | null) {
if (active === tag) {
// Click the active chip again to clear the filter.
tag = null;
}
active = tag;
loading = true;
error = null;
try {
const page = await listMyPageTags(
tag != null ? { tag, limit: 100 } : { limit: 100 }
);
items = page.items;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
</script>
{#if initialDistinct.length === 0 && items.length === 0}
<p class="hint" data-testid="library-page-tags-empty">
You haven't tagged any pages yet. Open a chapter and right-click
(or long-press on mobile) a page to add a tag.
</p>
{:else}
{#if initialDistinct.length > 0}
<div class="chip-cloud" data-testid="library-page-tags-chips">
{#each initialDistinct as s (s.tag)}
<button
type="button"
class="chip"
class:active={active === s.tag}
onclick={() => setActive(s.tag)}
data-testid={`library-page-tags-chip-${s.tag}`}
>
{s.tag}
<span class="count">{s.count}</span>
</button>
{/each}
</div>
{/if}
{#if error}
<p class="error" role="alert" data-testid="library-page-tags-error">{error}</p>
{/if}
{#if loading}
<p class="hint">Loading…</p>
{:else if items.length === 0}
<p class="hint" data-testid="library-page-tags-filtered-empty">
No pages tagged with "{active}".
</p>
{:else}
<ul class="list" data-testid="library-page-tags-list">
{#each items as p (`${p.tag}::${p.page_id}`)}
<TaggedPageRow item={p} />
{/each}
</ul>
{/if}
{/if}
<style>
.hint {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
.chip-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 2px var(--space-2);
font-size: var(--font-sm);
cursor: pointer;
}
.chip.active {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--primary);
}
.count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.chip.active .count {
color: var(--primary);
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
</style>

View File

@@ -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<typeof import('$lib/api/page_tags')>(
'$lib/api/page_tags'
);
return { ...actual, ...pageTagsApi };
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function tagged(extra: Record<string, unknown> = {}) {
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
});
});
});

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import type { TaggedChapterAggregate } from '$lib/api/page_tags';
/**
* Single row in the /search Chapters tab. Renders the breadcrumb
* (manga title · chapter label), a match-count pill, and a
* thumbnail strip of up to 3 matching pages. Clicking the row
* lands on the reader at chapter page 1; the reader will respect
* the user's single/continuous mode preference.
*/
let {
item,
testid
}: {
item: TaggedChapterAggregate;
testid?: string;
} = $props();
const href = $derived(
`/manga/${item.manga_id}/chapter/${item.chapter_id}`
);
const primaryCover = $derived(item.sample_storage_keys[0] ?? null);
</script>
<li class="row" data-testid={testid}>
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
{#if primaryCover}
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
{:else}
<div class="cover cover-placeholder"></div>
{/if}
</a>
<div class="meta">
<a class="title" {href}>
{item.manga_title} · {chapterLabel({
number: item.chapter_number,
title: item.chapter_title
})}
</a>
<span class="match-count">
{item.match_count}
{item.match_count === 1 ? 'page' : 'pages'}
</span>
{#if item.sample_storage_keys.length > 1}
<div class="samples">
{#each item.sample_storage_keys.slice(1) as key (key)}
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
{/each}
</div>
{/if}
</div>
</li>
<style>
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: start;
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
background: var(--surface-elevated);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--primary);
text-decoration: none;
}
.match-count {
align-self: flex-start;
background: var(--primary-soft-bg);
color: var(--primary);
font-size: var(--font-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
}
.samples {
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
}
.sample {
width: 40px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
</style>

View File

@@ -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<string, unknown> = {}) {
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();
});
});

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import type { TaggedMangaAggregate } from '$lib/api/page_tags';
/**
* Single row in the /search Mangas tab. Renders the manga cover,
* title, the cross-chapter match-count pill, and a thumbnail
* strip of matching pages. Clicking the row lands on the manga
* detail page.
*
* Cover precedence: `manga_cover_image_path` if the manga has
* one, else the first sample page. Mangas without uploaded
* covers (rare during dev / bots) still get a recognisable thumb.
*/
let {
item,
testid
}: {
item: TaggedMangaAggregate;
testid?: string;
} = $props();
const href = $derived(`/manga/${item.manga_id}`);
const primaryCover = $derived(
item.manga_cover_image_path ?? item.sample_storage_keys[0] ?? null
);
/** Sample strip skips the slot already used as the primary cover
* to avoid showing the same thumbnail twice. */
const sampleStrip = $derived(
item.manga_cover_image_path != null
? item.sample_storage_keys
: item.sample_storage_keys.slice(1)
);
</script>
<li class="row" data-testid={testid}>
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
{#if primaryCover}
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
{:else}
<div class="cover cover-placeholder"></div>
{/if}
</a>
<div class="meta">
<a class="title" {href}>{item.manga_title}</a>
<span class="match-count">
{item.match_count}
{item.match_count === 1 ? 'page' : 'pages'}
</span>
{#if sampleStrip.length > 0}
<div class="samples">
{#each sampleStrip as key (key)}
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
{/each}
</div>
{/if}
</div>
</li>
<style>
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: start;
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
background: var(--surface-elevated);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--primary);
text-decoration: none;
}
.match-count {
align-self: flex-start;
background: var(--primary-soft-bg);
color: var(--primary);
font-size: var(--font-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
}
.samples {
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
}
.sample {
width: 40px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
</style>

View File

@@ -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<string, unknown> = {}) {
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();
});
});

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { chapterLabel } from '$lib/api/chapters';
import type { TaggedPageItem } from '$lib/api/page_tags';
/**
* Single row in any tagged-page list — used by both the library
* Page-tags tab and the /search Pages tab. Links the cover and
* the breadcrumb text to the reader at `?page=N` so the user
* lands on the exact page they're looking at.
*
* `showTagPill` defaults true (library wants it). The /search
* Pages tab can suppress it when a tag filter is already active,
* to remove the noise of every row reading "#funny".
*/
let {
item,
showTagPill = true,
testid
}: {
item: TaggedPageItem;
showTagPill?: boolean;
testid?: string;
} = $props();
const readerHref = $derived(
`/manga/${item.manga_id}/chapter/${item.chapter_id}?page=${item.page_number}`
);
</script>
<li class="row" data-testid={testid}>
<a href={readerHref} class="cover-link" aria-hidden="true" tabindex="-1">
<img
src={fileUrl(item.storage_key)}
alt=""
class="cover"
loading="lazy"
/>
</a>
<div class="meta">
<a class="title" href={`/manga/${item.manga_id}`}>{item.manga_title}</a>
<a class="target" href={readerHref}>
{chapterLabel({
number: item.chapter_number,
title: item.chapter_title
})} — page {item.page_number}
</a>
{#if showTagPill}
<span class="tag-pill">#{item.tag}</span>
{/if}
</div>
</li>
<style>
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-3);
align-items: center;
}
.cover-link {
display: block;
line-height: 0;
}
.cover {
width: 56px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.meta {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.title {
font-weight: var(--weight-semibold);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
color: var(--primary);
text-decoration: none;
}
.target {
font-size: var(--font-sm);
color: var(--primary);
}
.tag-pill {
align-self: flex-start;
background: var(--surface-elevated);
color: var(--text-muted);
font-size: var(--font-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
}
</style>

View File

@@ -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<string, unknown> = {}) {
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();
});
});

View File

@@ -1,36 +1,153 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
let { let {
onPrev, onPrev,
onNext, onNext,
onToggle, onToggle,
onLongPress,
testid = 'tap-zone' testid = 'tap-zone'
}: { }: {
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
onToggle: () => void; onToggle: () => void;
/**
* Fires when a touch lingers > 450ms on any zone without
* moving. The viewport-coord `anchor` lets the reader open a
* sheet anchored to the press location. Desktop pointers
* (`pointerType !== 'touch'`) never trigger this — right-click
* is the desktop entry point and lives in the reader.
*/
onLongPress?: (anchor: { x: number; y: number }) => void;
testid?: string; testid?: string;
} = $props(); } = $props();
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE = 8;
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let pressStart: { x: number; y: number } | null = null;
// Self-expiring suppression: cleared either by an actual click
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
// timer below. The latter matters when the user lifts off the
// tap zone after a long-press (finger slid onto the sheet) — no
// click is synthesized, so without an expiry the flag would leak
// and eat the next legitimate tap.
const SUPPRESS_EXPIRY_MS = 500;
let suppressNextClick = false;
let suppressExpiryTimer: ReturnType<typeof setTimeout> | null = null;
function clearPress() {
if (pressTimer != null) {
clearTimeout(pressTimer);
pressTimer = null;
}
pressStart = null;
}
function clearSuppress() {
suppressNextClick = false;
if (suppressExpiryTimer != null) {
clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = null;
}
}
function onPointerDown(e: PointerEvent) {
if (!onLongPress) return;
if (e.pointerType !== 'touch') return;
clearPress();
pressStart = { x: e.clientX, y: e.clientY };
const startX = e.clientX;
const startY = e.clientY;
pressTimer = setTimeout(() => {
pressTimer = null;
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
onLongPress?.({ x: startX, y: startY });
}, LONG_PRESS_MS);
}
function onPointerMove(e: PointerEvent) {
if (pressTimer == null || pressStart == null) return;
const dx = e.clientX - pressStart.x;
const dy = e.clientY - pressStart.y;
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
}
function onPointerUp() {
clearPress();
}
function onPointerCancel() {
clearPress();
}
function onScroll() {
// Scrolling while pressed almost always means the user is
// panning, not deliberately holding. Cancel.
clearPress();
}
function withSuppress(handler: () => void) {
return () => {
if (suppressNextClick) {
clearSuppress();
return;
}
handler();
};
}
// onMount only runs on the client, so the window-scroll listener
// is automatically guarded — no SSR check needed. The teardown
// also clears any in-flight long-press timer.
onMount(() => {
if (!onLongPress) return;
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('scroll', onScroll, true);
clearPress();
clearSuppress();
};
});
</script> </script>
<div class="tap-zones" data-testid={testid}> <div class="tap-zones" data-testid={testid}>
<button <button
type="button" type="button"
class="zone left" class="zone left"
onclick={onPrev} onclick={withSuppress(onPrev)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Previous page" aria-label="Previous page"
data-testid="{testid}-left" data-testid="{testid}-left"
></button> ></button>
<button <button
type="button" type="button"
class="zone center" class="zone center"
onclick={onToggle} onclick={withSuppress(onToggle)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Toggle controls" aria-label="Toggle controls"
data-testid="{testid}-center" data-testid="{testid}-center"
></button> ></button>
<button <button
type="button" type="button"
class="zone right" class="zone right"
onclick={onNext} onclick={withSuppress(onNext)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
aria-label="Next page" aria-label="Next page"
data-testid="{testid}-right" data-testid="{testid}-right"
></button> ></button>

View File

@@ -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 { render, screen, cleanup } from '@testing-library/svelte';
import TapZone from './TapZone.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()); afterEach(() => cleanup());
describe('TapZone', () => { describe('TapZone', () => {
@@ -49,4 +90,147 @@ describe('TapZone', () => {
expect(screen.getByTestId('reader-tap-center')).toBeTruthy(); expect(screen.getByTestId('reader-tap-center')).toBeTruthy();
expect(screen.getByTestId('reader-tap-right')).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();
});
});
}); });

View File

@@ -18,6 +18,7 @@
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down'; import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -402,7 +403,22 @@
{/if} {/if}
{/snippet} {/snippet}
<h1>Mangas</h1> <div class="heading-row">
<h1>Mangas</h1>
<!--
Secondary entry point into the per-page search at /search,
which is headed "Page search". Label matches the destination's
title so the two read as one feature. It filters the user's
tagged pages and pivots between Pages / Chapters / Mangas;
title-search above stays the primary action. The name is also
forward-compatible with the planned OCR text search (which
searches page content, not just tags).
-->
<a class="alt-search" href="/search" data-testid="nav-page-search">
<span>Page search</span>
<ArrowRight size={14} aria-hidden="true" />
</a>
</div>
<form <form
onsubmit={onSubmit} onsubmit={onSubmit}
@@ -562,11 +578,38 @@
{/if} {/if}
<style> <style>
.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 { .controls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
margin-top: var(--space-3);
} }
.search-row { .search-row {

View File

@@ -5,7 +5,12 @@
removeMangaFromCollection, removeMangaFromCollection,
updateCollection updateCollection
} from '$lib/api/collections'; } from '$lib/api/collections';
import {
removePageFromCollection,
type CollectionPageItem
} from '$lib/api/page_collections';
import type { Manga } from '$lib/api/client'; import type { Manga } from '$lib/api/client';
import { fileUrl } from '$lib/api/client';
import MangaCard from '$lib/components/MangaCard.svelte'; import MangaCard from '$lib/components/MangaCard.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil'; import Pencil from '@lucide/svelte/icons/pencil';
@@ -18,6 +23,8 @@
let collection = $state({ ...data.collection }); let collection = $state({ ...data.collection });
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let mangas = $state<Manga[]>([...data.mangas]); let mangas = $state<Manga[]>([...data.mangas]);
// svelte-ignore state_referenced_locally
let pages = $state<CollectionPageItem[]>([...data.pages]);
let editing = $state(false); let editing = $state(false);
let editName = $state(''); let editName = $state('');
@@ -72,6 +79,17 @@
editError = (e as Error).message; 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;
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -162,28 +180,73 @@
{/if} {/if}
</header> </header>
{#if mangas.length === 0} {#if mangas.length === 0 && pages.length === 0}
<p class="status" data-testid="collection-empty"> <p class="status" data-testid="collection-empty">
This collection is empty. This collection is empty.
</p> </p>
{:else} {/if}
<ul class="manga-grid" data-testid="collection-manga-list">
{#each mangas as m (m.id)} {#if mangas.length > 0}
<li class="card-with-remove"> <section aria-labelledby="mangas-heading">
<MangaCard manga={m} testid={`collection-manga-${m.id}`} /> <h2 id="mangas-heading" class="section-heading">Mangas</h2>
<button <ul class="manga-grid" data-testid="collection-manga-list">
type="button" {#each mangas as m (m.id)}
class="remove" <li class="card-with-remove">
onclick={() => onRemoveManga(m)} <MangaCard manga={m} testid={`collection-manga-${m.id}`} />
aria-label={`Remove ${m.title} from collection`} <button
title="Remove from collection" type="button"
data-testid={`collection-remove-manga-${m.id}`} class="remove"
> onclick={() => onRemoveManga(m)}
<X size={14} aria-hidden="true" /> aria-label={`Remove ${m.title} from collection`}
</button> title="Remove from collection"
</li> data-testid={`collection-remove-manga-${m.id}`}
{/each} >
</ul> <X size={14} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if pages.length > 0}
<section aria-labelledby="pages-heading">
<h2 id="pages-heading" class="section-heading">Pages</h2>
<ul class="page-grid" data-testid="collection-page-list">
{#each pages as p (p.page_id)}
<li class="card-with-remove">
<a
class="page-card"
href={`/manga/${p.manga_id}/chapter/${p.chapter_id}?page=${p.page_number}`}
data-testid={`collection-page-${p.page_id}`}
>
<img
src={fileUrl(p.storage_key)}
alt={`${p.manga_title} chapter ${p.chapter_number} page ${p.page_number}`}
class="page-thumb"
loading="lazy"
/>
<span class="page-meta">
<span class="page-title">{p.manga_title}</span>
<span class="page-breadcrumb">
Ch. {p.chapter_number} · page {p.page_number}
</span>
</span>
</a>
<button
type="button"
class="remove"
onclick={() => onRemovePage(p)}
aria-label={`Remove ${p.manga_title} page ${p.page_number} from collection`}
title="Remove from collection"
data-testid={`collection-remove-page-${p.page_id}`}
>
<X size={14} aria-hidden="true" />
</button>
</li>
{/each}
</ul>
</section>
{/if} {/if}
<style> <style>
@@ -260,6 +323,67 @@
gap: var(--space-4); gap: var(--space-4);
} }
.section-heading {
margin: var(--space-5) 0 var(--space-3);
font-size: var(--font-lg);
}
.section-heading:first-of-type {
margin-top: 0;
}
.page-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
}
.page-card {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text);
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.page-card:hover {
border-color: var(--primary);
text-decoration: none;
}
.page-thumb {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
background: var(--surface-elevated);
}
.page-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2);
}
.page-title {
font-weight: var(--weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-breadcrumb {
color: var(--text-muted);
font-size: var(--font-xs);
}
.card-with-remove { .card-with-remove {
position: relative; position: relative;
list-style: none; list-style: none;

View File

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

View File

@@ -6,14 +6,16 @@
import SegmentedControl from '$lib/components/SegmentedControl.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import BookmarkList from '$lib/components/BookmarkList.svelte'; import BookmarkList from '$lib/components/BookmarkList.svelte';
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte'; import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
import PageTagsList from '$lib/components/PageTagsList.svelte';
import BookImage from '@lucide/svelte/icons/book-image'; import BookImage from '@lucide/svelte/icons/book-image';
let { data } = $props(); let { data } = $props();
type Tab = 'bookmarks' | 'collections' | 'history'; type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
const TABS: { label: string; value: Tab }[] = [ const TABS: { label: string; value: Tab }[] = [
{ label: 'Bookmarks', value: 'bookmarks' }, { label: 'Bookmarks', value: 'bookmarks' },
{ label: 'Collections', value: 'collections' }, { label: 'Collections', value: 'collections' },
{ label: 'Page tags', value: 'page-tags' },
{ label: 'History', value: 'history' } { label: 'History', value: 'history' }
]; ];
@@ -22,7 +24,8 @@
// default so visiting /library bare doesn't add noise to the URL. // default so visiting /library bare doesn't add noise to the URL.
const activeTab: Tab = $derived.by(() => { const activeTab: Tab = $derived.by(() => {
const t = $page.url.searchParams.get('tab'); const t = $page.url.searchParams.get('tab');
return t === 'collections' || t === 'history' ? t : 'bookmarks'; if (t === 'collections' || t === 'history' || t === 'page-tags') return t;
return 'bookmarks';
}); });
function setTab(t: Tab) { function setTab(t: Tab) {
@@ -34,6 +37,8 @@
const url = new URL($page.url); const url = new URL($page.url);
if (t === 'bookmarks') url.searchParams.delete('tab'); if (t === 'bookmarks') url.searchParams.delete('tab');
else url.searchParams.set('tab', t); else url.searchParams.set('tab', t);
// Cast away the literal union — the SegmentedControl is
// generic so its onchange receives `string`.
void goto(url.toString(), { void goto(url.toString(), {
replaceState: true, replaceState: true,
keepFocus: true, keepFocus: true,
@@ -81,6 +86,11 @@
{:else} {:else}
<CollectionsGrid collections={data.collections} /> <CollectionsGrid collections={data.collections} />
{/if} {/if}
{:else if activeTab === 'page-tags'}
<PageTagsList
initialItems={data.pageTags}
initialDistinct={data.distinctPageTags}
/>
{:else if data.history.length === 0} {:else if data.history.length === 0}
<p class="hint" data-testid="library-history-empty"> <p class="hint" data-testid="library-history-empty">
Nothing here yet — open any manga and a row will land here once you turn Nothing here yet — open any manga and a row will land here once you turn

View File

@@ -2,15 +2,19 @@ import { ApiError } from '$lib/api/client';
import { listMyBookmarks } from '$lib/api/bookmarks'; import { listMyBookmarks } from '$lib/api/bookmarks';
import { listMyCollections } from '$lib/api/collections'; import { listMyCollections } from '$lib/api/collections';
import { listMyReadProgress } from '$lib/api/read_progress'; import { listMyReadProgress } from '$lib/api/read_progress';
import {
listMyPageTags,
listMyDistinctPageTags
} from '$lib/api/page_tags';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
/** /**
* Loads bookmarks + collections + reading-history in one shot so the * Loads bookmarks + collections + history + page-tags in one shot so
* Library segmented control can swap between sub-tabs without firing a * the Library segmented control can swap between sub-tabs without
* second round trip per tap. 401 → unauthenticated path; the page * firing a second round trip per tap. 401 → unauthenticated path; the
* surfaces a sign-in prompt and renders empty lists. * page surfaces a sign-in prompt and renders empty lists.
*/ */
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
const empty = { const empty = {
@@ -18,19 +22,26 @@ export const load: PageLoad = async () => {
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'], bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'], collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'], history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'],
pageTags: [] as Awaited<ReturnType<typeof listMyPageTags>>['items'],
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
error: null as string | null error: null as string | null
}; };
try { try {
const [bookmarks, collections, history] = await Promise.all([ const [bookmarks, collections, history, pageTags, distinctPageTags] =
listMyBookmarks(), await Promise.all([
listMyCollections({ limit: 200 }), listMyBookmarks(),
listMyReadProgress({ limit: 100 }) listMyCollections({ limit: 200 }),
]); listMyReadProgress({ limit: 100 }),
listMyPageTags({ limit: 100 }),
listMyDistinctPageTags(undefined, 100)
]);
return { return {
...empty, ...empty,
bookmarks: bookmarks.items, bookmarks: bookmarks.items,
collections: collections.items, collections: collections.items,
history: history.items history: history.items,
pageTags: pageTags.items,
distinctPageTags
}; };
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 401) { if (e instanceof ApiError && e.status === 401) {

View File

@@ -567,7 +567,7 @@
{#if session.user} {#if session.user}
<AddToCollectionModal <AddToCollectionModal
open={collectionModalOpen} open={collectionModalOpen}
mangaId={manga.id} target={{ kind: 'manga', id: manga.id }}
onClose={() => (collectionModalOpen = false)} onClose={() => (collectionModalOpen = false)}
/> />
{/if} {/if}

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto, invalidateAll } from '$app/navigation'; import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { fileUrl, ApiError } from '$lib/api/client'; import { fileUrl, ApiError } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte'; import { preferences } from '$lib/preferences.svelte';
@@ -11,8 +11,18 @@
import { readerFullscreen } from '$lib/reader-fullscreen.svelte'; import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte'; import { session } from '$lib/session.svelte';
import Sheet from '$lib/components/Sheet.svelte'; import Sheet from '$lib/components/Sheet.svelte';
import Modal from '$lib/components/Modal.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import TapZone from '$lib/components/TapZone.svelte'; import TapZone from '$lib/components/TapZone.svelte';
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
import AddTagsSheet from '$lib/components/AddTagsSheet.svelte';
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import { getMyCollectionsContainingPage } from '$lib/api/page_collections';
import { getMyTagsForPage } from '$lib/api/page_tags';
import FolderPlus from '@lucide/svelte/icons/folder-plus';
import Tag from '@lucide/svelte/icons/tag';
import Download from '@lucide/svelte/icons/download';
import Link2 from '@lucide/svelte/icons/link-2';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
@@ -58,21 +68,26 @@
: null : null
); );
// Seed the initial page index from `?page=`. Numeric values are // Initial page index from `?page=`. Numeric values are 1-indexed
// 1-indexed and clamped to the chapter's page count; the sentinel // and clamped to the chapter's page count; the sentinel `last`
// `last` lands on the final page (used by the prev-chapter chevron // lands on the final page (used by the prev-chapter chevron when
// when going backwards through the series). Component remounts on // going backwards through the series).
// chapter navigation, so this only runs at the start of each //
// chapter — referencing `data` here is intentional. // `$derived` so it tracks `data.requestedPage` / `data.pages`
// svelte-ignore state_referenced_locally // across chapter navigation — SvelteKit reuses the component on
const initialIndex = (() => { // same-route nav (the chevrons at `jumpToPrevChapter` /
// `jumpToNextChapter` below call `goto(...)` to navigate between
// chapters), and a `const` would freeze initialIndex at the
// first chapter's value.
const initialIndex = $derived.by(() => {
const req = data.requestedPage; const req = data.requestedPage;
if (req === 'last') return Math.max(0, data.pages.length - 1); if (req === 'last') return Math.max(0, data.pages.length - 1);
if (typeof req === 'number') { if (typeof req === 'number') {
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1)); return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
} }
return 0; return 0;
})(); });
// svelte-ignore state_referenced_locally
let index = $state(initialIndex); let index = $state(initialIndex);
let continuousPageEls: HTMLImageElement[] = $state([]); let continuousPageEls: HTMLImageElement[] = $state([]);
let chapterBarEl: HTMLElement | undefined = $state(); let chapterBarEl: HTMLElement | undefined = $state();
@@ -88,6 +103,217 @@
let chapterJumpOpen = $state(false); let chapterJumpOpen = $state(false);
let settingsOpen = $state(false); let settingsOpen = $state(false);
// ---- Page context menu / tag + collection actions ----
//
// Desktop: right-click on a `.page-image` opens the floating
// `PageContextMenu`. Mobile: long-press (via TapZone in single
// mode, or per-image timer in continuous mode) opens an action
// sheet that funnels into the same modals. Unauthenticated users
// see neither — there's nothing for them to act on.
let contextMenuOpen = $state(false);
let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 });
let activePageId = $state<string | null>(null);
let activePageCollectionCount = $state<number | null>(null);
let activePageTags = $state<string[]>([]);
let actionSheetOpen = $state(false);
let collectionsModalOpen = $state(false);
let tagsModalOpen = $state(false);
// Monotonically-increasing token so a slow loadPageSummary for
// page A can't clobber a fresh load for page B. The user right-
// clicking one image then another on a slow network is the
// scenario; without the token the older request's resolution
// wins because it lands last.
let loadPageSummaryToken = 0;
async function loadPageSummary(pageId: string) {
const token = ++loadPageSummaryToken;
activePageCollectionCount = null;
activePageTags = [];
try {
const [ids, tags] = await Promise.all([
getMyCollectionsContainingPage(pageId),
getMyTagsForPage(pageId)
]);
if (token !== loadPageSummaryToken) return;
activePageCollectionCount = ids.length;
activePageTags = tags;
} catch {
// Soft-fail — context lines just stay empty / loading.
}
}
function openContextMenu(anchor: { x: number; y: number }, pageId: string) {
if (!session.user) return;
activePageId = pageId;
contextMenuAnchor = anchor;
contextMenuOpen = true;
void loadPageSummary(pageId);
}
function openActionSheet(pageId: string) {
if (!session.user) return;
activePageId = pageId;
actionSheetOpen = true;
void loadPageSummary(pageId);
}
function onPageContextMenu(e: MouseEvent, pageId: string) {
if (!session.user) return;
// Power-user escape hatch — Shift + right-click drops to the
// native browser context menu (image save, copy, inspect).
if (e.shiftKey) return;
e.preventDefault();
openContextMenu({ x: e.clientX, y: e.clientY }, pageId);
}
function handleAddToCollection() {
contextMenuOpen = false;
actionSheetOpen = false;
collectionsModalOpen = true;
}
function handleAddTag() {
contextMenuOpen = false;
actionSheetOpen = false;
tagsModalOpen = true;
}
function activePageNumber(): number | null {
if (!activePageId) return null;
const i = pages.findIndex((p) => p.id === activePageId);
return i >= 0 ? i + 1 : null;
}
function activeStorageKey(): string | null {
if (!activePageId) return null;
return pages.find((p) => p.id === activePageId)?.storage_key ?? null;
}
function handleSaveImage() {
const key = activeStorageKey();
contextMenuOpen = false;
actionSheetOpen = false;
if (!key) return;
// Open in a new tab — the browser surfaces its native "Save
// image as" / long-press save from there. `download` on an
// anchor would force the disk-save dialog, which is more
// direct but loses the in-tab preview some users prefer.
window.open(fileUrl(key), '_blank', 'noopener');
}
// Two-state pill: either "Link copied" (clipboard write OK) or a
// selectable URL pill (clipboard unavailable — insecure context,
// permissions denied). Auto-clears via timer; the failure pill
// sticks around for 10s (vs. 1.6s for success) so the user has
// time to long-press → Copy on mobile without the pill vanishing
// mid-gesture. The timer is torn down on chapter navigation so
// a stale fire doesn't try to write post-unmount state.
let linkCopiedState = $state<
{ kind: 'hidden' } | { kind: 'ok' } | { kind: 'manual'; url: string }
>({ kind: 'hidden' });
let linkCopiedTimer: ReturnType<typeof setTimeout> | null = null;
function clearLinkCopiedTimer() {
if (linkCopiedTimer != null) {
clearTimeout(linkCopiedTimer);
linkCopiedTimer = null;
}
}
async function handleCopyLink() {
const n = activePageNumber();
contextMenuOpen = false;
actionSheetOpen = false;
if (n == null) return;
const url = `${window.location.origin}/manga/${manga.id}/chapter/${chapter.id}?page=${n}`;
clearLinkCopiedTimer();
try {
await navigator.clipboard.writeText(url);
linkCopiedState = { kind: 'ok' };
linkCopiedTimer = setTimeout(() => {
linkCopiedState = { kind: 'hidden' };
linkCopiedTimer = null;
}, 1600);
} catch {
// Insecure context (HTTP on a LAN demo), missing
// permission, or some other API unavailability. Surface
// the URL in the pill so the user can copy it manually
// instead of silently swallowing — clipboard failures are
// systemic, not transient, and the silent path leaves the
// user pressing the button again with no feedback.
linkCopiedState = { kind: 'manual', url };
linkCopiedTimer = setTimeout(() => {
linkCopiedState = { kind: 'hidden' };
linkCopiedTimer = null;
}, 10000);
}
}
// Continuous mode lacks TapZone, so long-press lives per-image.
// Each in-flight press is keyed by `PointerEvent.pointerId` so a
// two-finger multitouch (one finger on each of two images) tracks
// both independently — a single shared timer would let the later
// press clobber the earlier one and fire the wrong page's sheet.
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE = 8;
type Press = {
start: { x: number; y: number };
timer: ReturnType<typeof setTimeout>;
};
const presses = new Map<number, Press>();
function clearPress(pointerId: number) {
const p = presses.get(pointerId);
if (!p) return;
clearTimeout(p.timer);
presses.delete(pointerId);
}
function clearAllPresses() {
for (const p of presses.values()) clearTimeout(p.timer);
presses.clear();
}
function onPagePointerDown(e: PointerEvent, pageId: string) {
if (e.pointerType !== 'touch') return;
if (!session.user) return;
clearPress(e.pointerId);
const start = { x: e.clientX, y: e.clientY };
const pointerId = e.pointerId;
const timer = setTimeout(() => {
presses.delete(pointerId);
openActionSheet(pageId);
}, LONG_PRESS_MS);
presses.set(pointerId, { start, timer });
}
function onPagePointerMove(e: PointerEvent) {
const p = presses.get(e.pointerId);
if (!p) return;
const dx = e.clientX - p.start.x;
const dy = e.clientY - p.start.y;
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress(e.pointerId);
}
function onPagePointerUp(e: PointerEvent) {
clearPress(e.pointerId);
}
// Scroll-pan typically fires pointercancel on the underlying touch,
// but not always — explicitly cancel every in-flight press on any
// scroll event so a long hold during a vertical pan never fires
// the action sheet for a page the user is already past. Matches
// the TapZone-level cancel-on-scroll rule.
$effect(() => {
if (!browser) return;
window.addEventListener('scroll', clearAllPresses, true);
return () => {
window.removeEventListener('scroll', clearAllPresses, true);
clearAllPresses();
};
});
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black // Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
// overlay opacity). Stored in localStorage only; the Preferences // overlay opacity). Stored in localStorage only; the Preferences
// table doesn't carry this field and Phase 4 explicitly opted to // table doesn't carry this field and Phase 4 explicitly opted to
@@ -125,6 +351,121 @@
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v; if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
}); });
// Continuous mode previously ignored `?page=N` — the single-mode
// `initialIndex` logic above wires it into `let index = $state(...)`
// but continuous lets the user scroll naturally, so nothing
// jumped to the requested page on load. Search-result click-
// through (and any shared link) now relies on it.
//
// Two subtleties this guards against:
// 1. Lazy-load layout shift: pages with `loading="lazy"` have
// 0×0 placeholder height until they load. `scrollIntoView`
// runs on whatever the current layout says — if prior pages
// haven't loaded, the target appears far higher than its
// final position, and as the images load the target gets
// pushed past the viewport. The template eager-loads pages
// `0..=initialIndex` so they have heights, and the effect
// waits for each of them to fire `load` (or `error`) before
// scrolling.
// 2. Mode hydration race: `mode` derives from `preferences.readerMode`,
// which initialises to 'single' and is hydrated async on
// cold caches. An `onMount` would bail before the flip; an
// `$effect` re-fires when mode changes. A one-shot sentinel
// keeps us from re-scrolling on later toggles.
// 3. In-reader chapter navigation: SvelteKit reuses this
// component on same-route goto(...) (see chevron handlers
// below). The sentinel + `index` get reset by the
// chapter-change effect below so the scroll fires again for
// the new chapter's `?page=N`.
let initialScrollDone = $state(false);
// Re-seed chapter-scoped state when the chevrons (or any in-
// reader link) navigate to a sibling chapter. SvelteKit reuses
// the component on same-route nav, so without this the previous
// chapter's mutable state bleeds into the new one:
// - `index` would keep the old chapter's value (possibly out
// of range for the new chapter's page count).
// - `initialScrollDone` would stay true, so `?page=N` deep-
// link scrolls would never re-fire.
// - `progressPage` would keep the old chapter's high-water
// mark, and a pending `progressTimer` would flush it against
// the new `chapter.id` — poisoning the new chapter's stored
// read progress.
//
// `lastChapterIdSeen` is a plain `let` rather than `$state` on
// purpose — it's only read inside this effect's body and never
// drives reactivity. Making it `$state` would not change behavior
// but would suggest to a future reader that the value matters
// elsewhere.
// Two things are load-bearing here:
//
// 1. `$derived(data.chapter.id)` makes the dependency explicit.
// Reading `data.chapter.id` directly inside an effect does
// not reliably re-fire when SvelteKit hands the page a new
// `data` prop with a swapped nested chapter object —
// verified by an E2E that stayed stuck on the previous
// chapter's index across the full polling window. The
// `$derived` exists for tracking, not memoization.
//
// 2. `$effect.pre` (vs. `$effect`) lands the reset BEFORE the
// DOM mutation rather than after. The page-indicator and
// everything else that reads `index` / `pages.length` then
// render once, with both values from the new chapter. With
// a plain `$effect` the indicator rendered the previous
// chapter's `index` against the new chapter's
// `pages.length` ("Page 5 / 4") — the very symptom the
// regression test pins.
const currentChapterId = $derived(data.chapter.id);
let lastChapterIdSeen: string | null = null;
$effect.pre(() => {
const cid = currentChapterId;
if (lastChapterIdSeen !== null && lastChapterIdSeen !== cid) {
index = initialIndex;
initialScrollDone = false;
progressPage = initialProgressPage;
if (progressTimer) {
clearTimeout(progressTimer);
progressTimer = null;
}
}
lastChapterIdSeen = cid;
});
$effect(() => {
if (initialScrollDone) return;
if (!browser) return;
if (mode !== 'continuous') return;
if (initialIndex === 0) {
initialScrollDone = true;
return;
}
const target = continuousPageEls[initialIndex];
// continuousPageEls is populated as the {#each} mounts; the
// effect re-runs once it's bound.
if (!target) return;
initialScrollDone = true;
const above = continuousPageEls
.slice(0, initialIndex + 1)
.filter((el): el is HTMLImageElement => el != null);
const pending = above.filter((el) => !el.complete);
if (pending.length === 0) {
target.scrollIntoView({ block: 'start' });
return;
}
let remaining = pending.length;
const onResolved = () => {
remaining -= 1;
if (remaining === 0) target.scrollIntoView({ block: 'start' });
};
for (const el of pending) {
// `error` counts as resolved — a broken image's height is
// settled at its alt-text height, and we shouldn't hang.
el.addEventListener('load', onResolved, { once: true });
el.addEventListener('error', onResolved, { once: true });
}
});
// Publish the dim level as a CSS variable on <html>. (1 - brightness) // Publish the dim level as a CSS variable on <html>. (1 - brightness)
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom // gives 0..0.7 alpha for the fixed overlay rendered at the bottom
// of the template. Persisting on every change keeps the // of the template. Persisting on every change keeps the
@@ -367,6 +708,10 @@
onMount(() => window.addEventListener('keydown', onKeydown)); onMount(() => window.addEventListener('keydown', onKeydown));
onDestroy(() => { onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown); if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
// Tear down the copy-pill timer so a fire-after-unmount
// doesn't try to write to a stale $state rune. Cheap on
// SvelteKit chapter navigation, which remounts this page.
clearLinkCopiedTimer();
}); });
// ---- Admin force resync (current chapter) ---- // ---- Admin force resync (current chapter) ----
@@ -411,14 +756,18 @@
// Writes are debounced and fire-and-forget — the reader never // Writes are debounced and fire-and-forget — the reader never
// blocks on the network, and a failed write just means the user's // blocks on the network, and a failed write just means the user's
// history is slightly stale (acceptable). // history is slightly stale (acceptable).
// Route param `[n]` is part of the URL, so SvelteKit remounts //
// this component on chapter navigation — capturing the initial // `$derived` so the seed recomputes on in-reader chapter
// `data` value here is the desired behaviour. // navigation (SvelteKit reuses the component on same-route nav).
// svelte-ignore state_referenced_locally // The chapter-change effect above re-seeds `progressPage` from
const initialProgressPage = // this — without it, the previous chapter's high-water mark
// carries over and gets written against the new `chapter.id`.
const initialProgressPage = $derived.by(() =>
data.readProgress && data.readProgress.chapter_id === chapter.id data.readProgress && data.readProgress.chapter_id === chapter.id
? Math.max(1, data.readProgress.page) ? Math.max(1, data.readProgress.page)
: 1; : 1
);
// svelte-ignore state_referenced_locally
let progressPage = $state(initialProgressPage); let progressPage = $state(initialProgressPage);
let progressTimer: ReturnType<typeof setTimeout> | null = null; let progressTimer: ReturnType<typeof setTimeout> | null = null;
let observer: IntersectionObserver | null = null; let observer: IntersectionObserver | null = null;
@@ -446,26 +795,62 @@
progressTimer = setTimeout(flushProgress, 1500); progressTimer = setTimeout(flushProgress, 1500);
} }
/** // Two-part reader back-control:
* Reader back behavior — pop browser history instead of pushing a // - The arrow is always a "go back" action: pops browser
* fresh entry for `/manga/{id}`. Previously this was a naked // history if there's something to pop, falls back to the
* `<a href>` and every tap pushed, so browser-back ping-ponged // detail page on a cold tab.
* between detail and reader instead of walking out to home. // - The cover+title is smart: if the user got to the reader
* // FROM this manga's detail page they go back (pop, no dup
* Just check `history.length > 1` — `document.referrer` does NOT // history entry), otherwise they land on the detail page
* update across SvelteKit SPA navigations, so referrer-based // (push). Stops the "click cover/title and end up in search
* gating silently fell back to the default href every time. // results" regression that the naked back-everywhere code
* Middle-click / cmd-click / right-click are passed through so // used to have.
* "open in new tab" still works. //
*/ // `lastInternalPath` is captured by `afterNavigate({ from })`,
function onBackClick(e: MouseEvent) { // which SvelteKit fires after every client-side navigation. It's
// the only reliable signal — `document.referrer` doesn't update
// across SPA navs (the chrome-resync at line 718 doesn't help
// either since it doesn't write referrer).
let lastInternalPath: string | null = null;
afterNavigate(({ from }) => {
lastInternalPath = from?.url?.pathname ?? null;
});
const detailPath = $derived(`/manga/${manga.id}`);
function onArrowClick(e: MouseEvent) {
if (!browser) return; if (!browser) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
if (window.history.length > 1) { if (window.history.length > 1) {
window.history.back();
} else {
// Cold tab / deep-link: nothing to pop. Push the detail
// page as the sane "back" destination.
void goto(detailPath);
}
}
function onCoverTitleClick(e: MouseEvent) {
if (!browser) return;
// Modifier / middle / non-left clicks fall through to the
// native <a> so "open in new tab", "copy link", etc. all work.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (lastInternalPath === detailPath) {
// Came from THIS manga's detail page — pop so we don't
// accumulate a duplicate /manga/:id entry. preventDefault
// here stops SvelteKit's own click handler: its listener
// is on the app container in the bubble phase and bails on
// `event.defaultPrevented` (see @sveltejs/kit client.js),
// and this element-level handler runs first at the target
// phase, so the pop wins cleanly.
e.preventDefault(); e.preventDefault();
window.history.back(); window.history.back();
} }
// else: let the href navigate (deep-link / fresh tab path) // Anywhere else (search, library, another manga's detail, cold
// tab) — do nothing here and let the <a href> navigate
// natively, which PUSHES the detail page so browser-back
// returns to the reader.
} }
// Single-mode: every page change moves the high-water mark. // Single-mode: every page change moves the high-water mark.
@@ -574,27 +959,46 @@
</svelte:head> </svelte:head>
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}> <nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
<a <div class="back-group">
href="/manga/{manga.id}" <button
class="back" type="button"
onclick={onBackClick} class="back-arrow"
data-testid="back-to-manga" aria-label="Back"
> onclick={onArrowClick}
<ArrowLeft size={18} aria-hidden="true" /> data-testid="reader-back-arrow"
{#if manga.cover_image_path} >
<img <ArrowLeft size={18} aria-hidden="true" />
src={fileUrl(manga.cover_image_path)} </button>
alt="" <!--
class="back-cover" Cover + title is a real <a href> to the detail page so
loading="lazy" cmd/middle-click "open in new tab" and "copy link" work
/> natively. The onclick only intercepts the one case where
{:else} we want to POP instead of push (arrived from this manga's
<span class="back-cover back-cover-placeholder" aria-hidden="true"> own detail page) — see onCoverTitleClick. Every other
<BookImage size={14} aria-hidden="true" /> plain-left-click falls through to SvelteKit's default <a>
</span> navigation, which pushes the detail page.
{/if} -->
<span class="back-text">{manga.title}</span> <a
</a> href={detailPath}
class="back"
onclick={onCoverTitleClick}
data-testid="back-to-manga"
>
{#if manga.cover_image_path}
<img
src={fileUrl(manga.cover_image_path)}
alt=""
class="back-cover"
loading="lazy"
/>
{:else}
<span class="back-cover back-cover-placeholder" aria-hidden="true">
<BookImage size={14} aria-hidden="true" />
</span>
{/if}
<span class="back-text">{manga.title}</span>
</a>
</div>
<div class="controls" role="group" aria-label="reader options"> <div class="controls" role="group" aria-label="reader options">
<label class="chapter-field desktop-control"> <label class="chapter-field desktop-control">
@@ -777,6 +1181,7 @@
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`} alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
class="page-image" class="page-image"
loading="eager" loading="eager"
oncontextmenu={(e) => onPageContextMenu(e, pages[index].id)}
data-testid="reader-page" data-testid="reader-page"
/> />
@@ -811,11 +1216,22 @@
{:else} {:else}
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous"> <div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
{#each pages as p, i (p.id)} {#each pages as p, i (p.id)}
<!-- Pages 0..=initialIndex (or at least 0..1) are eager
so their real heights are settled before the cold-
load scroll-to-`?page=N` effect fires. Without this,
`scrollIntoView(target)` lands while prior pages are
still 0×0 placeholders and the target gets pushed
past the viewport as they load. -->
<img <img
src={fileUrl(p.storage_key)} src={fileUrl(p.storage_key)}
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`} alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
class="page-image" class="page-image"
loading={i < 2 ? 'eager' : 'lazy'} loading={i <= Math.max(1, initialIndex) ? 'eager' : 'lazy'}
oncontextmenu={(e) => onPageContextMenu(e, p.id)}
onpointerdown={(e) => onPagePointerDown(e, p.id)}
onpointermove={onPagePointerMove}
onpointerup={onPagePointerUp}
onpointercancel={onPagePointerUp}
data-testid={`reader-page-${i + 1}`} data-testid={`reader-page-${i + 1}`}
bind:this={continuousPageEls[i]} bind:this={continuousPageEls[i]}
/> />
@@ -868,13 +1284,56 @@
DOM is one zero-opacity div when brightness is at max. --> DOM is one zero-opacity div when brightness is at max. -->
<div class="brightness-overlay" aria-hidden="true"></div> <div class="brightness-overlay" aria-hidden="true"></div>
{#if linkCopiedState.kind === 'ok'}
<div
class="link-copied"
role="status"
aria-live="polite"
data-testid="reader-link-copied"
>
Link copied
</div>
{:else if linkCopiedState.kind === 'manual'}
<div
class="link-copied link-copied-manual"
role="status"
aria-live="polite"
data-testid="reader-link-copied-manual"
>
<span class="link-copied-label">
Couldn't copy automatically press C / long-press to copy:
</span>
<!--
`readonly` keeps the underlying page state untouchable.
`inputmode="none"` suppresses the iOS / Android soft
keyboard that would otherwise pop up under a tap, since
the user is here to copy the URL, not edit it. Focus
auto-selects so the desktop user can immediately ⌘C.
-->
<input
type="text"
readonly
inputmode="none"
value={linkCopiedState.url}
onfocus={(e) => e.currentTarget.select()}
data-testid="reader-link-copied-manual-input"
/>
</div>
{/if}
<!-- Tap zones — mobile + single mode only. Continuous mode owns native <!-- Tap zones — mobile + single mode only. Continuous mode owns native
scroll so left/right would steal panning. Tap left/right advances scroll so left/right would steal panning. Tap left/right advances
within the chapter (falling through to adjacent chapters at the within the chapter (falling through to adjacent chapters at the
boundaries via the existing prev/next helpers); tap center toggles boundaries via the existing prev/next helpers); tap center toggles
the focus-mode chrome and restarts the idle timer. --> the focus-mode chrome and restarts the idle timer. -->
{#if isMobileViewport && mode === 'single' && pages.length > 0} {#if isMobileViewport && mode === 'single' && pages.length > 0}
<TapZone onPrev={prev} onNext={next} onToggle={toggleChrome} testid="reader-tap" /> <TapZone
onPrev={prev}
onNext={next}
onToggle={toggleChrome}
onLongPress={(_anchor) => openActionSheet(pages[index].id)}
testid="reader-tap"
/>
{/if} {/if}
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above <!-- Bottom scrubber — mobile + single + multi-page only. Lives above
@@ -981,6 +1440,119 @@
</div> </div>
</Sheet> </Sheet>
<!-- Page context menu (desktop right-click) and the modals/sheet it
funnels into. Rendered for authenticated users only — there's
nothing for a guest to act on. -->
{#if session.user}
<PageContextMenu
open={contextMenuOpen}
anchor={contextMenuAnchor}
onClose={() => (contextMenuOpen = false)}
onAddToCollection={handleAddToCollection}
onAddTag={handleAddTag}
onSaveImage={handleSaveImage}
onCopyLink={handleCopyLink}
collectionsCount={activePageCollectionCount}
tags={activePageTags}
/>
<!-- Mobile action sheet: same two actions, larger touch targets. -->
<Sheet
open={actionSheetOpen}
title="Page actions"
onClose={() => (actionSheetOpen = false)}
testid="page-action-sheet"
>
<ul class="action-list">
<li>
<button
type="button"
class="action-row"
onclick={handleAddToCollection}
data-testid="page-action-add-to-collection"
>
<FolderPlus size={18} aria-hidden="true" />
<span>Add to collection</span>
</button>
</li>
<li>
<button
type="button"
class="action-row"
onclick={handleAddTag}
data-testid="page-action-add-tag"
>
<Tag size={18} aria-hidden="true" />
<span>Add tag</span>
</button>
</li>
<li>
<button
type="button"
class="action-row"
onclick={handleSaveImage}
data-testid="page-action-save-image"
>
<Download size={18} aria-hidden="true" />
<span>Save image</span>
</button>
</li>
<li>
<button
type="button"
class="action-row"
onclick={handleCopyLink}
data-testid="page-action-copy-link"
>
<Link2 size={18} aria-hidden="true" />
<span>Copy page link</span>
</button>
</li>
</ul>
<p class="action-hint" data-testid="page-action-collections-line">
{#if activePageCollectionCount == null}
Loading
{:else if activePageCollectionCount === 0}
Not in any collection
{:else}
In {activePageCollectionCount} collection{activePageCollectionCount === 1
? ''
: 's'}
{/if}
</p>
<p class="action-hint" data-testid="page-action-tags-line">
{activePageTags.length === 0
? 'No tags yet'
: `Tagged: ${activePageTags.join(', ')}`}
</p>
</Sheet>
{#if activePageId}
<AddToCollectionModal
open={collectionsModalOpen}
target={{ kind: 'page', id: activePageId }}
onClose={() => {
collectionsModalOpen = false;
if (activePageId) void loadPageSummary(activePageId);
}}
/>
<Modal
open={tagsModalOpen}
title="Tag this page"
onClose={() => (tagsModalOpen = false)}
size="md"
testid="add-tags-modal"
>
<AddTagsSheet
pageId={activePageId}
onChange={(tags) => {
activePageTags = tags;
}}
/>
</Modal>
{/if}
{/if}
<style> <style>
/* Pinned to the viewport directly below the (also fixed) layout /* Pinned to the viewport directly below the (also fixed) layout
header. `position: fixed` rather than `sticky` because the header. `position: fixed` rather than `sticky` because the
@@ -1015,6 +1587,31 @@
pointer-events: none; pointer-events: none;
} }
.back-group {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
}
.back-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text);
border: 0;
padding: var(--space-1);
cursor: pointer;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.back-arrow:hover {
color: var(--primary);
background: var(--surface-elevated);
}
.back { .back {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1178,6 +1775,17 @@
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
/* Suppress the native mobile Safari long-press image callout
("Save Image", "Copy") and the long-press text selection so
the in-app long-press → action sheet path is the only
outcome. Cost: users lose the OS-level Save Image affordance
in the reader. Save image stays available via the in-app
action sheet.
Desktop right-click is handled separately by oncontextmenu
— holding Shift bypasses our handler for the native menu. */
-webkit-touch-callout: none;
user-select: none;
-webkit-user-select: none;
} }
.continuous .page-image { .continuous .page-image {
@@ -1447,6 +2055,89 @@
font-weight: var(--weight-regular); font-weight: var(--weight-regular);
} }
.action-list {
list-style: none;
margin: 0 0 var(--space-3);
padding: 0;
}
.action-row {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3);
background: transparent;
color: var(--text);
border: 0;
border-bottom: 1px solid var(--border);
font-size: var(--font-base);
cursor: pointer;
text-align: left;
min-height: 48px;
}
.action-row:hover {
background: var(--surface-elevated);
}
.action-hint {
margin: var(--space-1) 0 0;
padding: 0 var(--space-3);
color: var(--text-muted);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-copied {
position: fixed;
top: calc(var(--app-header-h) + var(--space-3));
left: 50%;
transform: translateX(-50%);
z-index: var(--z-modal);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: var(--space-1) var(--space-3);
font-size: var(--font-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
pointer-events: none;
}
.link-copied-manual {
/* Manual fallback needs to be interactive so the user can
select / copy the URL — override the success pill's
pointer-events: none. */
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--space-2);
max-width: min(90vw, 32rem);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
}
.link-copied-label {
color: var(--text-muted);
font-size: var(--font-xs);
white-space: nowrap;
}
.link-copied-manual input {
flex: 1;
min-width: 0;
font-family: inherit;
font-size: var(--font-xs);
background: var(--surface-elevated);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px var(--space-2);
}
.settings-group { .settings-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,351 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
import X from '@lucide/svelte/icons/x';
let { data } = $props();
type View = 'pages' | 'chapters' | 'mangas';
type Order = 'desc' | 'asc';
const VIEWS: { label: string; value: View }[] = [
{ label: 'Pages', value: 'pages' },
{ label: 'Chapters', value: 'chapters' },
{ label: 'Mangas', value: 'mangas' }
];
const ORDERS: { label: string; value: Order }[] = [
{ label: 'Most pages', value: 'desc' },
{ label: 'Fewest pages', value: 'asc' }
];
/**
* Update a single URL param and re-trigger the SvelteKit loader
* via goto with replaceState — same pattern as /library tab
* persistence. Passing `null` (or the param's default value)
* removes the param so the URL stays clean.
*/
function setParam(key: string, value: string | null) {
const url = new URL($page.url);
if (value == null || value === '') url.searchParams.delete(key);
else url.searchParams.set(key, value);
void goto(url.toString(), {
replaceState: true,
keepFocus: true,
noScroll: true
});
}
function setView(v: View) {
setParam('view', v === 'pages' ? null : v);
}
function setOrder(o: Order) {
setParam('order', o === 'desc' ? null : 'asc');
}
function setTag(t: string | null) {
// Reset view + order when the tag changes, so a user picking
// a fresh tag lands on the Pages tab with default sort.
const url = new URL($page.url);
url.searchParams.delete('view');
url.searchParams.delete('order');
if (t == null || t === '') url.searchParams.delete('tag');
else url.searchParams.set('tag', t);
void goto(url.toString(), {
replaceState: true,
keepFocus: true,
noScroll: true
});
}
// Tag input draft for the autocomplete chip cloud / dropdown.
let draft = $state('');
const filteredCloud = $derived.by(() => {
const q = draft.trim().toLowerCase();
if (!q) return data.distinct;
return data.distinct.filter((s) => s.tag.startsWith(q));
});
function onSubmitTag(e: SubmitEvent) {
e.preventDefault();
const q = draft.trim().toLowerCase();
if (!q) return;
// Pick the typed text directly — the backend normalizes and
// returns an empty result if the tag doesn't exist for this
// user, which the empty-state line handles cleanly.
setTag(q);
draft = '';
}
</script>
<svelte:head>
<title>Mangalord | Page search</title>
</svelte:head>
<h1 class="heading">Page search</h1>
{#if !data.authenticated}
<p class="hint" data-testid="search-signin">
<a href="/login?next=/search">Sign in</a> to search your tags.
</p>
{:else if data.error}
<p class="error" role="alert" data-testid="search-error">{data.error}</p>
{:else}
<!-- Tag filter. When a tag is selected it's shown as a chip with
an x to clear; when none is, the input doubles as the entry
point + autocomplete. -->
<section class="filter" aria-label="Tag filter">
{#if data.tag}
<div class="active-tag" data-testid="search-active-tag">
<span class="tag-pill">
{data.tag}
<button
type="button"
class="tag-clear"
aria-label="Clear tag"
onclick={() => setTag(null)}
data-testid="search-clear-tag"
>
<X size={12} aria-hidden="true" />
</button>
</span>
</div>
{:else}
<form class="tag-form" onsubmit={onSubmitTag} action="javascript:void(0)">
<input
type="text"
bind:value={draft}
placeholder="Type a tag and press Enter"
aria-label="Tag"
data-testid="search-tag-input"
/>
</form>
{/if}
</section>
{#if !data.tag}
<!-- Empty state: chip cloud doubles as autocomplete result. -->
{#if data.distinct.length === 0}
<p class="hint" data-testid="search-no-tags">
You haven't tagged any pages yet. Open a chapter and
right-click (or long-press on mobile) a page to add a
tag.
</p>
{:else if filteredCloud.length === 0}
<p class="hint" data-testid="search-no-matches">
No tags match "{draft}".
</p>
{:else}
<p class="cloud-hint">Browse your tags</p>
<div class="chip-cloud" data-testid="search-chip-cloud">
{#each filteredCloud as s (s.tag)}
<button
type="button"
class="chip"
onclick={() => setTag(s.tag)}
data-testid={`search-chip-${s.tag}`}
>
{s.tag}
<span class="count">{s.count}</span>
</button>
{/each}
</div>
<p class="empty-hint" data-testid="search-no-tag-selected">
No tag selected. Pick one above to see matching pages,
chapters, and mangas.
</p>
{/if}
{:else}
<!-- Tag selected: tabs + sort + results. -->
<div class="tab-row">
<SegmentedControl
ariaLabel="Result type"
value={data.view}
options={VIEWS}
onchange={setView}
testid="search-tabs"
/>
</div>
{#if data.view !== 'pages'}
<div class="tab-row">
<SegmentedControl
ariaLabel="Sort"
value={data.order}
options={ORDERS}
onchange={setOrder}
testid="search-sort"
/>
</div>
{/if}
{#if data.view === 'pages'}
{#if data.pages.length === 0}
<p class="hint" data-testid="search-pages-empty">
No pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-pages-list">
{#each data.pages as p (p.page_id)}
<TaggedPageRow
item={p}
showTagPill={false}
testid={`search-page-row-${p.page_id}`}
/>
{/each}
</ul>
{/if}
{:else if data.view === 'chapters'}
{#if data.chapters.length === 0}
<p class="hint" data-testid="search-chapters-empty">
No chapters contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-chapters-list">
{#each data.chapters as c (c.chapter_id)}
<TaggedChapterRow
item={c}
testid={`search-chapter-row-${c.chapter_id}`}
/>
{/each}
</ul>
{/if}
{:else}
{#if data.mangas.length === 0}
<p class="hint" data-testid="search-mangas-empty">
No mangas contain pages tagged with "{data.tag}".
</p>
{:else}
<ul class="list" data-testid="search-mangas-list">
{#each data.mangas as m (m.manga_id)}
<TaggedMangaRow
item={m}
testid={`search-manga-row-${m.manga_id}`}
/>
{/each}
</ul>
{/if}
{/if}
{/if}
{/if}
<style>
.heading {
margin-bottom: var(--space-3);
}
.hint,
.empty-hint {
color: var(--text-muted);
}
.empty-hint {
margin-top: var(--space-4);
text-align: center;
}
.cloud-hint {
margin: var(--space-3) 0 var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
.error {
color: var(--danger);
}
.filter {
margin-bottom: var(--space-3);
}
.tag-form input {
width: 100%;
max-width: 24rem;
}
.active-tag {
display: flex;
align-items: center;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary-soft-bg);
color: var(--primary);
border-radius: var(--radius-pill);
padding: 2px var(--space-2);
font-size: var(--font-sm);
}
.tag-clear {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: inherit;
border: 0;
padding: 0;
cursor: pointer;
line-height: 0;
}
.tag-clear:hover {
color: var(--text);
}
.chip-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 2px var(--space-2);
font-size: var(--font-sm);
cursor: pointer;
}
.chip:hover {
background: var(--surface-elevated);
}
.count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.tab-row {
margin-bottom: var(--space-3);
display: flex;
}
.tab-row :global(.segmented) {
width: 100%;
}
.tab-row :global(.seg) {
flex: 1;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
</style>

View File

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