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);
// 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); collection.manga_count = Math.max(0, collection.manga_count - 1);
}
} else { } else {
await addMangaToCollection(collection.id, mangaId); await addTo(collection.id, target);
if (target.kind === 'manga') {
collection.manga_count += 1; 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>
{#if target.kind === 'manga'}
<!-- The count is the manga count only.
Suppress it on page targets to
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"> <span class="row-count">
{c.manga_count} {c.manga_count}
{c.manga_count === 1 ? 'manga' : 'mangas'} {c.manga_count === 1 ? 'manga' : 'mangas'}
</span> </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,11 +180,15 @@
{/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}
{#if mangas.length > 0}
<section aria-labelledby="mangas-heading">
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
<ul class="manga-grid" data-testid="collection-manga-list"> <ul class="manga-grid" data-testid="collection-manga-list">
{#each mangas as m (m.id)} {#each mangas as m (m.id)}
<li class="card-with-remove"> <li class="card-with-remove">
@@ -184,6 +206,47 @@
</li> </li>
{/each} {/each}
</ul> </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] =
await Promise.all([
listMyBookmarks(), listMyBookmarks(),
listMyCollections({ limit: 200 }), listMyCollections({ limit: 200 }),
listMyReadProgress({ limit: 100 }) 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,13 +959,31 @@
</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}
data-testid="reader-back-arrow"
> >
<ArrowLeft size={18} aria-hidden="true" /> <ArrowLeft size={18} aria-hidden="true" />
</button>
<!--
Cover + title is a real <a href> to the detail page so
cmd/middle-click "open in new tab" and "copy link" work
natively. The onclick only intercepts the one case where
we want to POP instead of push (arrived from this manga's
own detail page) — see onCoverTitleClick. Every other
plain-left-click falls through to SvelteKit's default <a>
navigation, which pushes the detail page.
-->
<a
href={detailPath}
class="back"
onclick={onCoverTitleClick}
data-testid="back-to-manga"
>
{#if manga.cover_image_path} {#if manga.cover_image_path}
<img <img
src={fileUrl(manga.cover_image_path)} src={fileUrl(manga.cover_image_path)}
@@ -595,6 +998,7 @@
{/if} {/if}
<span class="back-text">{manga.title}</span> <span class="back-text">{manga.title}</span>
</a> </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;
}
};