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:
@@ -136,6 +136,8 @@ docker compose -f docker-compose.dev.yml up -d
|
||||
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
|
||||
|
||||
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
||||
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user personal page tags live in `page_tags` (distinct from the shared manga `tags` taxonomy; free-form text, normalized at the API layer). Both tables cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design.
|
||||
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints is **reserved** for the planned OCR text-search input. Both aggregation handlers accept `text=` on the wire but reject non-empty values with 501 `text_search_not_yet_supported` so adding OCR later doesn't break the API shape. Adding OCR is then: a background worker writes `page_ocr_text` rows, a JOIN on the existing aggregation queries adds the new filter, the `text=` param starts validating instead of rejecting.
|
||||
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
|
||||
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
|
||||
- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change.
|
||||
|
||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.60.2"
|
||||
version = "0.62.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.60.2"
|
||||
version = "0.62.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
48
backend/migrations/0023_page_collections_and_tags.sql
Normal file
48
backend/migrations/0023_page_collections_and_tags.sql
Normal 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);
|
||||
@@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::collection::{
|
||||
Collection, CollectionPatch, CollectionSummary, NewCollection,
|
||||
Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
|
||||
};
|
||||
use crate::domain::manga::Manga;
|
||||
use crate::domain::patch::Patch;
|
||||
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
|
||||
"/collections/:id/mangas/:manga_id",
|
||||
delete(remove_manga),
|
||||
)
|
||||
.route("/collections/:id/pages", get(list_pages).post(add_page))
|
||||
.route("/collections/:id/pages/:page_id", delete(remove_page))
|
||||
.route(
|
||||
"/mangas/:id/my-collections",
|
||||
get(list_my_collections_containing),
|
||||
)
|
||||
.route(
|
||||
"/pages/:id/my-collections",
|
||||
get(list_my_collections_containing_page),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
|
||||
pub manga_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPageBody {
|
||||
pub page_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MangaCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PageCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> AppResult<()> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
|
||||
Ok(Json(MangaCollectionIds { collection_ids: ids }))
|
||||
}
|
||||
|
||||
async fn list_pages(
|
||||
State(state): State<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
|
||||
/// "exists but belongs to someone else" surface as `NotFound` so the
|
||||
/// API doesn't disclose collection existence to non-owners — the
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod genres;
|
||||
pub mod health;
|
||||
pub mod history;
|
||||
pub mod mangas;
|
||||
pub mod page_tags;
|
||||
pub mod pagination;
|
||||
pub mod tags;
|
||||
|
||||
@@ -28,6 +29,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(tags::routes())
|
||||
.merge(authors::routes())
|
||||
.merge(collections::routes())
|
||||
.merge(page_tags::routes())
|
||||
.merge(history::routes())
|
||||
.merge(admin::routes())
|
||||
}
|
||||
|
||||
443
backend/src/api/page_tags.rs
Normal file
443
backend/src/api/page_tags.rs
Normal 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(¶ms.tag)?;
|
||||
let order = parse_order(params.order.as_deref())?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
|
||||
&state.db, user.id, &tag, order, limit, offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_mangas_for_tag(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<AggregateParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
|
||||
ensure_text_unsupported(params.text.as_deref())?;
|
||||
let tag = normalize_tag(¶ms.tag)?;
|
||||
let order = parse_order(params.order.as_deref())?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
|
||||
&state.db, user.id, &tag, order, limit, offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_tag;
|
||||
|
||||
#[test]
|
||||
fn lowercases_and_collapses_whitespace() {
|
||||
assert_eq!(normalize_tag("Funny").unwrap(), "funny");
|
||||
assert_eq!(normalize_tag(" Foo Bar ").unwrap(), "foo bar");
|
||||
assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_blank_input() {
|
||||
assert!(normalize_tag("").is_err());
|
||||
assert!(normalize_tag(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars() {
|
||||
assert!(normalize_tag("bad\ntag").is_err());
|
||||
assert!(normalize_tag("a\tb").is_err());
|
||||
// NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked
|
||||
// here so a future stdlib drift surfaces as a test fail rather
|
||||
// than a silent acceptance.
|
||||
assert!(normalize_tag("a\u{0085}b").is_err());
|
||||
// NULL byte.
|
||||
assert!(normalize_tag("a\u{0000}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_edge_colon() {
|
||||
assert!(normalize_tag(":foo").is_err());
|
||||
assert!(normalize_tag("foo:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invisible_format_chars() {
|
||||
// ZWJ — visually indistinguishable from no-zwj, would split
|
||||
// "funny" and "funny\u{200d}" into distinct rows.
|
||||
assert!(normalize_tag("funny\u{200d}").is_err());
|
||||
// RLO — bidi override could let a tag display backwards.
|
||||
assert!(normalize_tag("a\u{202e}b").is_err());
|
||||
// ZWNBSP / BOM at the boundary survives `trim()`.
|
||||
assert!(normalize_tag("a\u{feff}b").is_err());
|
||||
// ZWSP, RLM also rejected.
|
||||
assert!(normalize_tag("a\u{200b}b").is_err());
|
||||
assert!(normalize_tag("a\u{200f}b").is_err());
|
||||
// Plane-14 LANGUAGE TAG + TAG-character block. These tunnel
|
||||
// ASCII invisibly via the supplementary tag mechanism.
|
||||
assert!(normalize_tag("a\u{E0001}b").is_err());
|
||||
assert!(normalize_tag("a\u{E0020}b").is_err());
|
||||
assert!(normalize_tag("a\u{E007F}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_breaking_and_like_wildcards() {
|
||||
// `/` would survive %-encoding round-trip but axum decodes
|
||||
// %2F back to `/` after path routing, so the DELETE path
|
||||
// would never match. Same for `?`, `#`, `\`.
|
||||
assert!(normalize_tag("a/b").is_err());
|
||||
assert!(normalize_tag("a\\b").is_err());
|
||||
assert!(normalize_tag("a?b").is_err());
|
||||
assert!(normalize_tag("a#b").is_err());
|
||||
// `%` and `_` are SQL LIKE wildcards in the prefix filter.
|
||||
assert!(normalize_tag("a%b").is_err());
|
||||
assert!(normalize_tag("a_b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
let long = "a".repeat(65);
|
||||
assert!(normalize_tag(&long).is_err());
|
||||
let ok_len = "a".repeat(64);
|
||||
assert!(normalize_tag(&ok_len).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pathologically_long_input_before_allocating() {
|
||||
// 10 MiB of `a` — should fail-fast on the byte cap, not
|
||||
// allocate twice through the lowercase/whitespace passes.
|
||||
let huge = "a".repeat(10 * 1024 * 1024);
|
||||
assert!(normalize_tag(&huge).is_err());
|
||||
// Just under cap still goes through the rest of the pipeline
|
||||
// and fails on the char count.
|
||||
let near_cap = "a".repeat(super::MAX_TAG_BYTES);
|
||||
assert!(normalize_tag(&near_cap).is_err());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,22 @@ pub struct CollectionSummary {
|
||||
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)]
|
||||
pub struct NewCollection {
|
||||
pub name: String,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod collection;
|
||||
pub mod genre;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod page_tag;
|
||||
pub mod patch;
|
||||
pub mod read_progress;
|
||||
pub mod session;
|
||||
@@ -21,10 +22,14 @@ pub use api_token::ApiToken;
|
||||
pub use author::{Author, AuthorRef, AuthorWithCount};
|
||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||
pub use chapter::Chapter;
|
||||
pub use collection::{Collection, CollectionSummary};
|
||||
pub use collection::{Collection, CollectionPageItem, CollectionSummary};
|
||||
pub use genre::{Genre, GenreRef};
|
||||
pub use manga::{Manga, MangaCard, MangaDetail};
|
||||
pub use page::Page;
|
||||
pub use page_tag::{
|
||||
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
|
||||
TaggedPageItem,
|
||||
};
|
||||
pub use patch::Patch;
|
||||
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
||||
pub use session::Session;
|
||||
|
||||
63
backend/src/domain/page_tag.rs
Normal file
63
backend/src/domain/page_tag.rs
Normal 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>,
|
||||
}
|
||||
@@ -38,6 +38,16 @@ pub enum AppError {
|
||||
message: String,
|
||||
details: serde_json::Value,
|
||||
},
|
||||
/// 501 — the wire shape is accepted but the feature isn't built yet.
|
||||
/// Carries a `&'static str` snake_case code so clients can detect
|
||||
/// the specific pending feature (`text_search_not_yet_supported`,
|
||||
/// etc.) without parsing the message. Used today by the `?text=`
|
||||
/// reservation on the page-tag aggregation endpoints.
|
||||
#[error("not implemented: {code}")]
|
||||
NotImplemented {
|
||||
code: &'static str,
|
||||
message: &'static str,
|
||||
},
|
||||
#[error(transparent)]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
@@ -64,6 +74,7 @@ impl AppError {
|
||||
AppError::ServiceUnavailable(_) => "service_unavailable",
|
||||
AppError::TooManyRequests { .. } => "too_many_requests",
|
||||
AppError::ValidationFailed { .. } => "validation_failed",
|
||||
AppError::NotImplemented { code, .. } => code,
|
||||
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
|
||||
AppError::Database(_) => "internal_error",
|
||||
AppError::Storage(StorageError::NotFound) => "not_found",
|
||||
@@ -124,6 +135,11 @@ impl IntoResponse for AppError {
|
||||
message.clone(),
|
||||
Some(details.clone()),
|
||||
),
|
||||
AppError::NotImplemented { message, .. } => (
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
(*message).to_string(),
|
||||
None,
|
||||
),
|
||||
AppError::Database(sqlx::Error::RowNotFound) => {
|
||||
(StatusCode::NOT_FOUND, "not found".to_string(), None)
|
||||
}
|
||||
@@ -180,5 +196,15 @@ mod tests {
|
||||
assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
|
||||
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
|
||||
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
|
||||
// NotImplemented carries the code through so each pending
|
||||
// feature gets its own stable identifier on the wire.
|
||||
assert_eq!(
|
||||
AppError::NotImplemented {
|
||||
code: "text_search_not_yet_supported",
|
||||
message: "x"
|
||||
}
|
||||
.code(),
|
||||
"text_search_not_yet_supported"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::collection::{Collection, CollectionSummary};
|
||||
use crate::domain::collection::{Collection, CollectionPageItem, CollectionSummary};
|
||||
use crate::domain::manga::Manga;
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
@@ -278,3 +278,132 @@ pub async fn list_collections_containing(
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
/// Add a page to a collection. Same `(true → 201, false → 200)`
|
||||
/// idempotency contract as `add_manga`. FK violations (page deleted
|
||||
/// between the handler's existence check and this insert) surface as
|
||||
/// `NotFound`, not a 500.
|
||||
pub async fn add_page(
|
||||
pool: &PgPool,
|
||||
collection_id: Uuid,
|
||||
page_id: Uuid,
|
||||
) -> AppResult<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())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod crawler;
|
||||
pub mod genre;
|
||||
pub mod manga;
|
||||
pub mod page;
|
||||
pub mod page_tag;
|
||||
pub mod read_progress;
|
||||
pub mod session;
|
||||
pub mod tag;
|
||||
|
||||
374
backend/src/repo/page_tag.rs
Normal file
374
backend/src/repo/page_tag.rs
Normal 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))
|
||||
}
|
||||
|
||||
331
backend/tests/api_collection_pages.rs
Normal file
331
backend/tests/api_collection_pages.rs
Normal 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);
|
||||
}
|
||||
646
backend/tests/api_page_tags.rs
Normal file
646
backend/tests/api_page_tags.rs
Normal 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);
|
||||
}
|
||||
@@ -53,7 +53,13 @@ async function mockApis(page: Page) {
|
||||
);
|
||||
// Catalog returns a single card whose href points at MID so the
|
||||
// SPA-click walk lands on the manga we've mocked downstream.
|
||||
await page.route('**/api/v1/mangas', (r) =>
|
||||
// `**/api/v1/mangas` alone doesn't intercept query strings
|
||||
// (`?limit=&sort=`), so the catalog request would fall through
|
||||
// to a real backend if one is running on :8080 and seed the
|
||||
// page with real-manga IDs that don't match the mock body.
|
||||
// `**/api/v1/mangas?**` (and the same suffix on the catch-all
|
||||
// below) makes the intercept query-tolerant.
|
||||
await page.route('**/api/v1/mangas?**', (r) =>
|
||||
r.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -163,4 +169,56 @@ test('phone viewport: reader back pops history (does not push), then detail back
|
||||
await page.waitForURL((url) => url.pathname === '/');
|
||||
});
|
||||
|
||||
test('reader cover+title pushes detail when arrived from a non-detail page', async ({
|
||||
page
|
||||
}) => {
|
||||
// The smart-cover-title fix: if the user got to the reader from
|
||||
// a page OTHER than this manga's detail (search, library,
|
||||
// direct link, etc.), the cover+title should push the detail
|
||||
// page so browser-back returns to the reader. The arrow stays
|
||||
// a pure "go back".
|
||||
//
|
||||
// We simulate "non-detail arrival" with a deep-link load
|
||||
// straight to the reader. That covers cold-tab + shared-link +
|
||||
// search-result cases — afterNavigate fires with from=null and
|
||||
// lastInternalPath stays null, so the cover+title click sees
|
||||
// a mismatch and pushes the detail page.
|
||||
await mockApis(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await page.goto(`/manga/${MID}/chapter/${CID}`);
|
||||
await page.waitForSelector('[data-testid="back-to-manga"]');
|
||||
const lenAtReader = await page.evaluate(() => window.history.length);
|
||||
|
||||
await page.getByTestId('back-to-manga').click();
|
||||
await page.waitForURL(/\/manga\/[^/]+$/);
|
||||
const lenAfter = await page.evaluate(() => window.history.length);
|
||||
// PUSH (not pop) — lenAtReader + 1.
|
||||
expect(lenAfter).toBe(lenAtReader + 1);
|
||||
|
||||
// Browser-back from detail returns to reader (proves it was a
|
||||
// push, not a replace).
|
||||
await page.goBack();
|
||||
await page.waitForURL(/\/chapter\//);
|
||||
});
|
||||
|
||||
test('reader arrow always pops history', async ({ page }) => {
|
||||
await mockApis(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await page.goto('/');
|
||||
await page.locator('[data-testid="manga-list"] a').first().click();
|
||||
await page.waitForURL(/\/manga\/[^/]+$/);
|
||||
await page.locator('[data-testid="chapter-list"] a').first().click();
|
||||
await page.waitForURL(/\/chapter\//);
|
||||
|
||||
const lenAtReader = await page.evaluate(() => window.history.length);
|
||||
|
||||
// Click the arrow — always pops, regardless of previous page.
|
||||
await page.getByTestId('reader-back-arrow').click();
|
||||
await page.waitForURL(/\/manga\/[^/]+$/);
|
||||
const lenAfter = await page.evaluate(() => window.history.length);
|
||||
expect(lenAfter).toBe(lenAtReader);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
330
frontend/e2e/page-context-menu.spec.ts
Normal file
330
frontend/e2e/page-context-menu.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
250
frontend/e2e/reader-page-deep-link.spec.ts
Normal file
250
frontend/e2e/reader-page-deep-link.spec.ts
Normal 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
251
frontend/e2e/search.spec.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.60.2",
|
||||
"version": "0.62.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
101
frontend/src/lib/api/page_collections.test.ts
Normal file
101
frontend/src/lib/api/page_collections.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
68
frontend/src/lib/api/page_collections.ts
Normal file
68
frontend/src/lib/api/page_collections.ts
Normal 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;
|
||||
}
|
||||
166
frontend/src/lib/api/page_tags.test.ts
Normal file
166
frontend/src/lib/api/page_tags.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
145
frontend/src/lib/api/page_tags.ts
Normal file
145
frontend/src/lib/api/page_tags.ts
Normal 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)}`
|
||||
);
|
||||
}
|
||||
315
frontend/src/lib/components/AddTagsSheet.svelte
Normal file
315
frontend/src/lib/components/AddTagsSheet.svelte
Normal 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>
|
||||
113
frontend/src/lib/components/AddTagsSheet.svelte.test.ts
Normal file
113
frontend/src/lib/components/AddTagsSheet.svelte.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,32 @@
|
||||
removeMangaFromCollection,
|
||||
type CollectionSummary
|
||||
} from '$lib/api/collections';
|
||||
import {
|
||||
addPageToCollection,
|
||||
getMyCollectionsContainingPage,
|
||||
removePageFromCollection
|
||||
} from '$lib/api/page_collections';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
/**
|
||||
* Discriminated-union target so the same modal serves both the
|
||||
* manga-detail "Add to collection" flow and the reader's page
|
||||
* context menu. The list / pre-check / toggle / create-and-add
|
||||
* scaffolding is identical for both — only the leaf API call
|
||||
* differs, dispatched through `loadContaining` / `addTo` /
|
||||
* `removeFrom` below.
|
||||
*/
|
||||
export type Target =
|
||||
| { kind: 'manga'; id: string }
|
||||
| { kind: 'page'; id: string };
|
||||
|
||||
let {
|
||||
open,
|
||||
mangaId,
|
||||
target,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
mangaId: string;
|
||||
target: Target;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -28,23 +45,42 @@
|
||||
let loading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Refetch every time the modal opens (and when the manga id changes
|
||||
// mid-session — unlikely but cheap). The data is per-user and per-
|
||||
// manga, so re-fetching is the simplest way to stay in sync with
|
||||
// changes made elsewhere (e.g., a collection deleted on another page).
|
||||
// Refetch on open and whenever the target changes (e.g., the user
|
||||
// right-clicks a different page while the modal is in flight). The
|
||||
// dependency on `target.id` is explicit so the $effect re-runs.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// Touching target.id wires the reactive dependency.
|
||||
void target.id;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadContaining(t: Target): Promise<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() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [page, ids] = await Promise.all([
|
||||
listMyCollections({ limit: 200 }),
|
||||
getMyCollectionsContaining(mangaId)
|
||||
loadContaining(target)
|
||||
]);
|
||||
collections = page.items;
|
||||
containingIds = new Set(ids);
|
||||
@@ -55,9 +91,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Functional set updates that read the latest state at mutation
|
||||
// time, so concurrent toggles on different rows don't clobber
|
||||
// each other by building from a stale snapshot.
|
||||
function withAdd<T>(s: Set<T>, v: T): Set<T> {
|
||||
const n = new Set(s);
|
||||
n.add(v);
|
||||
@@ -72,21 +105,27 @@
|
||||
async function toggle(collection: CollectionSummary) {
|
||||
if (busyIds.has(collection.id)) return;
|
||||
const wasIn = containingIds.has(collection.id);
|
||||
// Optimistic toggle — local set first; revert on failure.
|
||||
containingIds = wasIn
|
||||
? withDelete(containingIds, collection.id)
|
||||
: withAdd(containingIds, collection.id);
|
||||
busyIds = withAdd(busyIds, collection.id);
|
||||
try {
|
||||
if (wasIn) {
|
||||
await removeMangaFromCollection(collection.id, mangaId);
|
||||
collection.manga_count = Math.max(0, collection.manga_count - 1);
|
||||
await removeFrom(collection.id, target);
|
||||
// manga_count drifts when pages are toggled — the
|
||||
// backend doesn't track page_count yet, so we only
|
||||
// adjust the count for the manga path. Page targets
|
||||
// leave manga_count as-is.
|
||||
if (target.kind === 'manga') {
|
||||
collection.manga_count = Math.max(0, collection.manga_count - 1);
|
||||
}
|
||||
} else {
|
||||
await addMangaToCollection(collection.id, mangaId);
|
||||
collection.manga_count += 1;
|
||||
await addTo(collection.id, target);
|
||||
if (target.kind === 'manga') {
|
||||
collection.manga_count += 1;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Revert (read latest containingIds, not the pre-toggle snapshot).
|
||||
containingIds = wasIn
|
||||
? withAdd(containingIds, collection.id)
|
||||
: withDelete(containingIds, collection.id);
|
||||
@@ -103,15 +142,11 @@
|
||||
error = null;
|
||||
try {
|
||||
const created = await createCollection({ name });
|
||||
// The list endpoint sorts by updated_at DESC; adding the
|
||||
// manga immediately also bumps it. Append a synthetic
|
||||
// summary so the new collection appears checked-on right
|
||||
// away rather than waiting for a refetch.
|
||||
await addMangaToCollection(created.id, mangaId);
|
||||
await addTo(created.id, target);
|
||||
collections = [
|
||||
{
|
||||
...created,
|
||||
manga_count: 1,
|
||||
manga_count: target.kind === 'manga' ? 1 : 0,
|
||||
sample_covers: []
|
||||
},
|
||||
...collections
|
||||
@@ -156,10 +191,20 @@
|
||||
/>
|
||||
<span class="row-label">
|
||||
<span class="row-name">{c.name}</span>
|
||||
<span class="row-count">
|
||||
{c.manga_count}
|
||||
{c.manga_count === 1 ? 'manga' : 'mangas'}
|
||||
</span>
|
||||
{#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">
|
||||
{c.manga_count}
|
||||
{c.manga_count === 1 ? 'manga' : 'mangas'}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
100
frontend/src/lib/components/AddToCollectionModal.svelte.test.ts
Normal file
100
frontend/src/lib/components/AddToCollectionModal.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
231
frontend/src/lib/components/PageContextMenu.svelte
Normal file
231
frontend/src/lib/components/PageContextMenu.svelte
Normal 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>
|
||||
109
frontend/src/lib/components/PageContextMenu.svelte.test.ts
Normal file
109
frontend/src/lib/components/PageContextMenu.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
144
frontend/src/lib/components/PageTagsList.svelte
Normal file
144
frontend/src/lib/components/PageTagsList.svelte
Normal 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>
|
||||
85
frontend/src/lib/components/PageTagsList.svelte.test.ts
Normal file
85
frontend/src/lib/components/PageTagsList.svelte.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/components/TaggedChapterRow.svelte
Normal file
123
frontend/src/lib/components/TaggedChapterRow.svelte
Normal 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>
|
||||
67
frontend/src/lib/components/TaggedChapterRow.svelte.test.ts
Normal file
67
frontend/src/lib/components/TaggedChapterRow.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
127
frontend/src/lib/components/TaggedMangaRow.svelte
Normal file
127
frontend/src/lib/components/TaggedMangaRow.svelte
Normal 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>
|
||||
72
frontend/src/lib/components/TaggedMangaRow.svelte.test.ts
Normal file
72
frontend/src/lib/components/TaggedMangaRow.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
108
frontend/src/lib/components/TaggedPageRow.svelte
Normal file
108
frontend/src/lib/components/TaggedPageRow.svelte
Normal 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>
|
||||
58
frontend/src/lib/components/TaggedPageRow.svelte.test.ts
Normal file
58
frontend/src/lib/components/TaggedPageRow.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
onPrev,
|
||||
onNext,
|
||||
onToggle,
|
||||
onLongPress,
|
||||
testid = 'tap-zone'
|
||||
}: {
|
||||
onPrev: () => void;
|
||||
onNext: () => 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;
|
||||
} = $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>
|
||||
|
||||
<div class="tap-zones" data-testid={testid}>
|
||||
<button
|
||||
type="button"
|
||||
class="zone left"
|
||||
onclick={onPrev}
|
||||
onclick={withSuppress(onPrev)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
aria-label="Previous page"
|
||||
data-testid="{testid}-left"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="zone center"
|
||||
onclick={onToggle}
|
||||
onclick={withSuppress(onToggle)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
aria-label="Toggle controls"
|
||||
data-testid="{testid}-center"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="zone right"
|
||||
onclick={onNext}
|
||||
onclick={withSuppress(onNext)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
aria-label="Next page"
|
||||
data-testid="{testid}-right"
|
||||
></button>
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import TapZone from './TapZone.svelte';
|
||||
|
||||
// jsdom does not implement PointerEvent. Construct a MouseEvent of the
|
||||
// correct type and decorate it with the `pointerType` + coords the
|
||||
// component reads — Svelte forwards it to the onpointerdown handler
|
||||
// unchanged, and the guard `e.pointerType === 'touch'` reads our
|
||||
// property.
|
||||
function pointerEvent(
|
||||
type: string,
|
||||
init: { pointerType?: string; clientX?: number; clientY?: number } = {}
|
||||
): Event {
|
||||
const ev = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
clientX: init.clientX,
|
||||
clientY: init.clientY
|
||||
});
|
||||
if (init.pointerType != null) {
|
||||
Object.defineProperty(ev, 'pointerType', {
|
||||
value: init.pointerType,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
function pointerDown(
|
||||
el: Element,
|
||||
init: { pointerType: string; clientX: number; clientY: number }
|
||||
) {
|
||||
el.dispatchEvent(pointerEvent('pointerdown', init));
|
||||
}
|
||||
|
||||
function pointerMove(
|
||||
el: Element,
|
||||
init: { clientX: number; clientY: number }
|
||||
) {
|
||||
el.dispatchEvent(pointerEvent('pointermove', init));
|
||||
}
|
||||
|
||||
function pointerUp(el: Element) {
|
||||
el.dispatchEvent(pointerEvent('pointerup'));
|
||||
}
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('TapZone', () => {
|
||||
@@ -49,4 +90,147 @@ describe('TapZone', () => {
|
||||
expect(screen.getByTestId('reader-tap-center')).toBeTruthy();
|
||||
expect(screen.getByTestId('reader-tap-right')).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('long-press', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('fires onLongPress with anchor coords after 450ms touch hold', () => {
|
||||
const onLongPress = vi.fn();
|
||||
const onNext = vi.fn();
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext,
|
||||
onToggle: () => {},
|
||||
onLongPress
|
||||
}
|
||||
});
|
||||
const right = screen.getByTestId('tap-zone-right');
|
||||
pointerDown(right, {
|
||||
pointerType: 'touch',
|
||||
clientX: 200,
|
||||
clientY: 300
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(onLongPress).toHaveBeenCalledWith({ x: 200, y: 300 });
|
||||
// The synthesized click that follows the long-press is
|
||||
// suppressed so the next-page handler doesn't fire.
|
||||
right.click();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire onLongPress for mouse pointers', () => {
|
||||
const onLongPress = vi.fn();
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext: () => {},
|
||||
onToggle: () => {},
|
||||
onLongPress
|
||||
}
|
||||
});
|
||||
const center = screen.getByTestId('tap-zone-center');
|
||||
pointerDown(center, {
|
||||
pointerType: 'mouse',
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
});
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels long-press when pointer moves beyond tolerance', () => {
|
||||
const onLongPress = vi.fn();
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext: () => {},
|
||||
onToggle: () => {},
|
||||
onLongPress
|
||||
}
|
||||
});
|
||||
const right = screen.getByTestId('tap-zone-right');
|
||||
pointerDown(right, {
|
||||
pointerType: 'touch',
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
});
|
||||
pointerMove(right, { clientX: 200, clientY: 100 });
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels long-press on pointer up before threshold', () => {
|
||||
const onLongPress = vi.fn();
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext: () => {},
|
||||
onToggle: () => {},
|
||||
onLongPress
|
||||
}
|
||||
});
|
||||
const center = screen.getByTestId('tap-zone-center');
|
||||
pointerDown(center, {
|
||||
pointerType: 'touch',
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
});
|
||||
pointerUp(center);
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('regular click still fires onNext when no long-press handler', () => {
|
||||
const onNext = vi.fn();
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext,
|
||||
onToggle: () => {}
|
||||
}
|
||||
});
|
||||
screen.getByTestId('tap-zone-right').click();
|
||||
expect(onNext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('suppression auto-expires so the next legitimate tap fires after a slide-off long-press', () => {
|
||||
// Scenario: user long-presses, finger slides off the zone
|
||||
// onto the opened sheet, releases there. No click is
|
||||
// synthesized on the zone, so without the expiry the
|
||||
// suppression would leak and eat the next tap. The 500ms
|
||||
// expiry inside the long-press callback caps the
|
||||
// suppression window.
|
||||
const onLongPress = vi.fn();
|
||||
const onNext = vi.fn();
|
||||
render(TapZone, {
|
||||
props: {
|
||||
onPrev: () => {},
|
||||
onNext,
|
||||
onToggle: () => {},
|
||||
onLongPress
|
||||
}
|
||||
});
|
||||
const right = screen.getByTestId('tap-zone-right');
|
||||
pointerDown(right, {
|
||||
pointerType: 'touch',
|
||||
clientX: 100,
|
||||
clientY: 100
|
||||
});
|
||||
// Long-press fires.
|
||||
vi.advanceTimersByTime(450);
|
||||
expect(onLongPress).toHaveBeenCalledOnce();
|
||||
// Drain the suppression-expiry window without a click —
|
||||
// simulates the user sliding off and releasing elsewhere.
|
||||
vi.advanceTimersByTime(600);
|
||||
// Fresh tap on the zone — must NOT be eaten.
|
||||
right.click();
|
||||
expect(onNext).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -402,7 +403,22 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<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
|
||||
onsubmit={onSubmit}
|
||||
@@ -562,11 +578,38 @@
|
||||
{/if}
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
removeMangaFromCollection,
|
||||
updateCollection
|
||||
} from '$lib/api/collections';
|
||||
import {
|
||||
removePageFromCollection,
|
||||
type CollectionPageItem
|
||||
} from '$lib/api/page_collections';
|
||||
import type { Manga } from '$lib/api/client';
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
@@ -18,6 +23,8 @@
|
||||
let collection = $state({ ...data.collection });
|
||||
// svelte-ignore state_referenced_locally
|
||||
let mangas = $state<Manga[]>([...data.mangas]);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let pages = $state<CollectionPageItem[]>([...data.pages]);
|
||||
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
@@ -72,6 +79,17 @@
|
||||
editError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemovePage(p: CollectionPageItem) {
|
||||
const snapshot = pages;
|
||||
pages = pages.filter((x) => x.page_id !== p.page_id);
|
||||
try {
|
||||
await removePageFromCollection(collection.id, p.page_id);
|
||||
} catch (e) {
|
||||
pages = snapshot;
|
||||
editError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -162,28 +180,73 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if mangas.length === 0}
|
||||
{#if mangas.length === 0 && pages.length === 0}
|
||||
<p class="status" data-testid="collection-empty">
|
||||
This collection is empty.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="manga-grid" data-testid="collection-manga-list">
|
||||
{#each mangas as m (m.id)}
|
||||
<li class="card-with-remove">
|
||||
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={() => onRemoveManga(m)}
|
||||
aria-label={`Remove ${m.title} from collection`}
|
||||
title="Remove from collection"
|
||||
data-testid={`collection-remove-manga-${m.id}`}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
{#each mangas as m (m.id)}
|
||||
<li class="card-with-remove">
|
||||
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={() => onRemoveManga(m)}
|
||||
aria-label={`Remove ${m.title} from collection`}
|
||||
title="Remove from collection"
|
||||
data-testid={`collection-remove-manga-${m.id}`}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</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}
|
||||
|
||||
<style>
|
||||
@@ -260,6 +323,67 @@
|
||||
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 {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
|
||||
@@ -4,33 +4,30 @@ import {
|
||||
getCollection,
|
||||
listCollectionMangas
|
||||
} from '$lib/api/collections';
|
||||
import { listCollectionPages } from '$lib/api/page_collections';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params, url }) => {
|
||||
try {
|
||||
const [collection, mangas] = await Promise.all([
|
||||
const [collection, mangas, pages] = await Promise.all([
|
||||
getCollection(params.id),
|
||||
listCollectionMangas(params.id, { limit: 200 })
|
||||
listCollectionMangas(params.id, { limit: 200 }),
|
||||
listCollectionPages(params.id, { limit: 200 })
|
||||
]);
|
||||
return {
|
||||
collection,
|
||||
mangas: mangas.items,
|
||||
total: mangas.page.total
|
||||
total: mangas.page.total,
|
||||
pages: pages.items
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
// 401 means the user's session is gone — bounce to login
|
||||
// and preserve where they wanted to go.
|
||||
if (e.status === 401) {
|
||||
const next = encodeURIComponent(url.pathname);
|
||||
redirect(302, `/login?next=${next}`);
|
||||
}
|
||||
// 403 (post-Phase-3-polish the backend collapses this to
|
||||
// 404 already, but keep the branch for defense-in-depth)
|
||||
// and 404 both render the standard not-found page so the
|
||||
// URL doesn't disclose collection existence to non-owners.
|
||||
if (e.status === 404 || e.status === 403) {
|
||||
error(404, 'Collection not found');
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type Tab = 'bookmarks' | 'collections' | 'history';
|
||||
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||
const TABS: { label: string; value: Tab }[] = [
|
||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||
{ label: 'Collections', value: 'collections' },
|
||||
{ label: 'Page tags', value: 'page-tags' },
|
||||
{ label: 'History', value: 'history' }
|
||||
];
|
||||
|
||||
@@ -22,7 +24,8 @@
|
||||
// default so visiting /library bare doesn't add noise to the URL.
|
||||
const activeTab: Tab = $derived.by(() => {
|
||||
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) {
|
||||
@@ -34,6 +37,8 @@
|
||||
const url = new URL($page.url);
|
||||
if (t === 'bookmarks') url.searchParams.delete('tab');
|
||||
else url.searchParams.set('tab', t);
|
||||
// Cast away the literal union — the SegmentedControl is
|
||||
// generic so its onchange receives `string`.
|
||||
void goto(url.toString(), {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
@@ -81,6 +86,11 @@
|
||||
{:else}
|
||||
<CollectionsGrid collections={data.collections} />
|
||||
{/if}
|
||||
{:else if activeTab === 'page-tags'}
|
||||
<PageTagsList
|
||||
initialItems={data.pageTags}
|
||||
initialDistinct={data.distinctPageTags}
|
||||
/>
|
||||
{:else if data.history.length === 0}
|
||||
<p class="hint" data-testid="library-history-empty">
|
||||
Nothing here yet — open any manga and a row will land here once you turn
|
||||
|
||||
@@ -2,15 +2,19 @@ import { ApiError } from '$lib/api/client';
|
||||
import { listMyBookmarks } from '$lib/api/bookmarks';
|
||||
import { listMyCollections } from '$lib/api/collections';
|
||||
import { listMyReadProgress } from '$lib/api/read_progress';
|
||||
import {
|
||||
listMyPageTags,
|
||||
listMyDistinctPageTags
|
||||
} from '$lib/api/page_tags';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
/**
|
||||
* Loads bookmarks + collections + reading-history in one shot so the
|
||||
* Library segmented control can swap between sub-tabs without firing a
|
||||
* second round trip per tap. 401 → unauthenticated path; the page
|
||||
* surfaces a sign-in prompt and renders empty lists.
|
||||
* Loads bookmarks + collections + history + page-tags in one shot so
|
||||
* the Library segmented control can swap between sub-tabs without
|
||||
* firing a second round trip per tap. 401 → unauthenticated path; the
|
||||
* page surfaces a sign-in prompt and renders empty lists.
|
||||
*/
|
||||
export const load: PageLoad = async () => {
|
||||
const empty = {
|
||||
@@ -18,19 +22,26 @@ export const load: PageLoad = async () => {
|
||||
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
|
||||
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['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
|
||||
};
|
||||
try {
|
||||
const [bookmarks, collections, history] = await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 })
|
||||
]);
|
||||
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||
await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 }),
|
||||
listMyPageTags({ limit: 100 }),
|
||||
listMyDistinctPageTags(undefined, 100)
|
||||
]);
|
||||
return {
|
||||
...empty,
|
||||
bookmarks: bookmarks.items,
|
||||
collections: collections.items,
|
||||
history: history.items
|
||||
history: history.items,
|
||||
pageTags: pageTags.items,
|
||||
distinctPageTags
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
|
||||
@@ -567,7 +567,7 @@
|
||||
{#if session.user}
|
||||
<AddToCollectionModal
|
||||
open={collectionModalOpen}
|
||||
mangaId={manga.id}
|
||||
target={{ kind: 'manga', id: manga.id }}
|
||||
onClose={() => (collectionModalOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
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 { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||
import { preferences } from '$lib/preferences.svelte';
|
||||
@@ -11,8 +11,18 @@
|
||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.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 ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
@@ -58,21 +68,26 @@
|
||||
: null
|
||||
);
|
||||
|
||||
// Seed the initial page index from `?page=`. Numeric values are
|
||||
// 1-indexed and clamped to the chapter's page count; the sentinel
|
||||
// `last` lands on the final page (used by the prev-chapter chevron
|
||||
// when going backwards through the series). Component remounts on
|
||||
// chapter navigation, so this only runs at the start of each
|
||||
// chapter — referencing `data` here is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialIndex = (() => {
|
||||
// Initial page index from `?page=`. Numeric values are 1-indexed
|
||||
// and clamped to the chapter's page count; the sentinel `last`
|
||||
// lands on the final page (used by the prev-chapter chevron when
|
||||
// going backwards through the series).
|
||||
//
|
||||
// `$derived` so it tracks `data.requestedPage` / `data.pages`
|
||||
// across chapter navigation — SvelteKit reuses the component on
|
||||
// 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;
|
||||
if (req === 'last') return Math.max(0, data.pages.length - 1);
|
||||
if (typeof req === 'number') {
|
||||
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let index = $state(initialIndex);
|
||||
let continuousPageEls: HTMLImageElement[] = $state([]);
|
||||
let chapterBarEl: HTMLElement | undefined = $state();
|
||||
@@ -88,6 +103,217 @@
|
||||
let chapterJumpOpen = $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
|
||||
// overlay opacity). Stored in localStorage only; the Preferences
|
||||
// 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;
|
||||
});
|
||||
|
||||
// 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)
|
||||
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
|
||||
// of the template. Persisting on every change keeps the
|
||||
@@ -367,6 +708,10 @@
|
||||
onMount(() => window.addEventListener('keydown', onKeydown));
|
||||
onDestroy(() => {
|
||||
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) ----
|
||||
@@ -411,14 +756,18 @@
|
||||
// Writes are debounced and fire-and-forget — the reader never
|
||||
// blocks on the network, and a failed write just means the user's
|
||||
// history is slightly stale (acceptable).
|
||||
// Route param `[n]` is part of the URL, so SvelteKit remounts
|
||||
// this component on chapter navigation — capturing the initial
|
||||
// `data` value here is the desired behaviour.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialProgressPage =
|
||||
//
|
||||
// `$derived` so the seed recomputes on in-reader chapter
|
||||
// navigation (SvelteKit reuses the component on same-route nav).
|
||||
// The chapter-change effect above re-seeds `progressPage` from
|
||||
// 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
|
||||
? Math.max(1, data.readProgress.page)
|
||||
: 1;
|
||||
: 1
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let progressPage = $state(initialProgressPage);
|
||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
@@ -446,26 +795,62 @@
|
||||
progressTimer = setTimeout(flushProgress, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader back behavior — pop browser history instead of pushing a
|
||||
* fresh entry for `/manga/{id}`. Previously this was a naked
|
||||
* `<a href>` and every tap pushed, so browser-back ping-ponged
|
||||
* between detail and reader instead of walking out to home.
|
||||
*
|
||||
* Just check `history.length > 1` — `document.referrer` does NOT
|
||||
* update across SvelteKit SPA navigations, so referrer-based
|
||||
* gating silently fell back to the default href every time.
|
||||
* Middle-click / cmd-click / right-click are passed through so
|
||||
* "open in new tab" still works.
|
||||
*/
|
||||
function onBackClick(e: MouseEvent) {
|
||||
// Two-part reader back-control:
|
||||
// - The arrow is always a "go back" action: pops browser
|
||||
// history if there's something to pop, falls back to the
|
||||
// detail page on a cold tab.
|
||||
// - The cover+title is smart: if the user got to the reader
|
||||
// FROM this manga's detail page they go back (pop, no dup
|
||||
// history entry), otherwise they land on the detail page
|
||||
// (push). Stops the "click cover/title and end up in search
|
||||
// results" regression that the naked back-everywhere code
|
||||
// used to have.
|
||||
//
|
||||
// `lastInternalPath` is captured by `afterNavigate({ from })`,
|
||||
// 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 (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
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();
|
||||
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.
|
||||
@@ -574,27 +959,46 @@
|
||||
</svelte:head>
|
||||
|
||||
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
|
||||
<a
|
||||
href="/manga/{manga.id}"
|
||||
class="back"
|
||||
onclick={onBackClick}
|
||||
data-testid="back-to-manga"
|
||||
>
|
||||
<ArrowLeft size={18} aria-hidden="true" />
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
class="back-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||
<BookImage size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
<div class="back-group">
|
||||
<button
|
||||
type="button"
|
||||
class="back-arrow"
|
||||
aria-label="Back"
|
||||
onclick={onArrowClick}
|
||||
data-testid="reader-back-arrow"
|
||||
>
|
||||
<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}
|
||||
<img
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
class="back-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||
<BookImage size={14} aria-hidden="true" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="back-text">{manga.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="controls" role="group" aria-label="reader options">
|
||||
<label class="chapter-field desktop-control">
|
||||
@@ -777,6 +1181,7 @@
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
|
||||
class="page-image"
|
||||
loading="eager"
|
||||
oncontextmenu={(e) => onPageContextMenu(e, pages[index].id)}
|
||||
data-testid="reader-page"
|
||||
/>
|
||||
|
||||
@@ -811,11 +1216,22 @@
|
||||
{:else}
|
||||
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||
{#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
|
||||
src={fileUrl(p.storage_key)}
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||
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}`}
|
||||
bind:this={continuousPageEls[i]}
|
||||
/>
|
||||
@@ -868,13 +1284,56 @@
|
||||
DOM is one zero-opacity div when brightness is at max. -->
|
||||
<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
|
||||
scroll so left/right would steal panning. Tap left/right advances
|
||||
within the chapter (falling through to adjacent chapters at the
|
||||
boundaries via the existing prev/next helpers); tap center toggles
|
||||
the focus-mode chrome and restarts the idle timer. -->
|
||||
{#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}
|
||||
|
||||
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
|
||||
@@ -981,6 +1440,119 @@
|
||||
</div>
|
||||
</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>
|
||||
/* Pinned to the viewport directly below the (also fixed) layout
|
||||
header. `position: fixed` rather than `sticky` because the
|
||||
@@ -1015,6 +1587,31 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1178,6 +1775,17 @@
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
@@ -1447,6 +2055,89 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
351
frontend/src/routes/search/+page.svelte
Normal file
351
frontend/src/routes/search/+page.svelte
Normal 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>
|
||||
92
frontend/src/routes/search/+page.ts
Normal file
92
frontend/src/routes/search/+page.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user