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.
|
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
|
||||||
|
|
||||||
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
||||||
|
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user personal page tags live in `page_tags` (distinct from the shared manga `tags` taxonomy; free-form text, normalized at the API layer). Both tables cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design.
|
||||||
|
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints is **reserved** for the planned OCR text-search input. Both aggregation handlers accept `text=` on the wire but reject non-empty values with 501 `text_search_not_yet_supported` so adding OCR later doesn't break the API shape. Adding OCR is then: a background worker writes `page_ocr_text` rows, a JOIN on the existing aggregation queries adds the new filter, the `text=` param starts validating instead of rejecting.
|
||||||
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
|
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
|
||||||
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
|
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
|
||||||
- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change.
|
- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change.
|
||||||
|
|||||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.60.2"
|
version = "0.62.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.60.2"
|
version = "0.62.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
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::app::AppState;
|
||||||
use crate::auth::extractor::CurrentUser;
|
use crate::auth::extractor::CurrentUser;
|
||||||
use crate::domain::collection::{
|
use crate::domain::collection::{
|
||||||
Collection, CollectionPatch, CollectionSummary, NewCollection,
|
Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
|
||||||
};
|
};
|
||||||
use crate::domain::manga::Manga;
|
use crate::domain::manga::Manga;
|
||||||
use crate::domain::patch::Patch;
|
use crate::domain::patch::Patch;
|
||||||
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
|
|||||||
"/collections/:id/mangas/:manga_id",
|
"/collections/:id/mangas/:manga_id",
|
||||||
delete(remove_manga),
|
delete(remove_manga),
|
||||||
)
|
)
|
||||||
|
.route("/collections/:id/pages", get(list_pages).post(add_page))
|
||||||
|
.route("/collections/:id/pages/:page_id", delete(remove_page))
|
||||||
.route(
|
.route(
|
||||||
"/mangas/:id/my-collections",
|
"/mangas/:id/my-collections",
|
||||||
get(list_my_collections_containing),
|
get(list_my_collections_containing),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/pages/:id/my-collections",
|
||||||
|
get(list_my_collections_containing_page),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_NAME_LEN: usize = 64;
|
const MAX_NAME_LEN: usize = 64;
|
||||||
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
|
|||||||
pub manga_id: Uuid,
|
pub manga_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddPageBody {
|
||||||
|
pub page_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct MangaCollectionIds {
|
pub struct MangaCollectionIds {
|
||||||
pub collection_ids: Vec<Uuid>,
|
pub collection_ids: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PageCollectionIds {
|
||||||
|
pub collection_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_name(name: &str) -> AppResult<()> {
|
fn validate_name(name: &str) -> AppResult<()> {
|
||||||
let trimmed = name.trim();
|
let trimmed = name.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
|
|||||||
Ok(Json(MangaCollectionIds { collection_ids: ids }))
|
Ok(Json(MangaCollectionIds { collection_ids: ids }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_pages(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(params): Query<ListParams>,
|
||||||
|
) -> AppResult<Json<PagedResponse<CollectionPageItem>>> {
|
||||||
|
require_owner_id(&state, user.id, id).await?;
|
||||||
|
let limit = params.limit.clamp(1, 200);
|
||||||
|
let offset = params.offset.max(0);
|
||||||
|
let (items, total) =
|
||||||
|
repo::collection::list_pages(&state.db, id, limit, offset).await?;
|
||||||
|
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<AddPageBody>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
require_owner_id(&state, user.id, id).await?;
|
||||||
|
// FK violation in `repo::collection::add_page` maps to NotFound, so
|
||||||
|
// no separate `repo::page::exists` check is needed — the insert is
|
||||||
|
// the existence check.
|
||||||
|
let created = repo::collection::add_page(&state.db, id, body.page_id).await?;
|
||||||
|
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path((collection_id, page_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
require_owner_id(&state, user.id, collection_id).await?;
|
||||||
|
repo::collection::remove_page(&state.db, collection_id, page_id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_my_collections_containing_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(page_id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<PageCollectionIds>> {
|
||||||
|
// Mirrors `list_my_collections_containing` for pages: unknown page
|
||||||
|
// returns an empty list, not 404 — keeps the endpoint side-effect-
|
||||||
|
// free and skips a distinguishing-status oracle.
|
||||||
|
let ids = repo::collection::list_collections_containing_page(&state.db, user.id, page_id)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(PageCollectionIds { collection_ids: ids }))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the row iff the caller owns it. Both "doesn't exist" and
|
/// Returns the row iff the caller owns it. Both "doesn't exist" and
|
||||||
/// "exists but belongs to someone else" surface as `NotFound` so the
|
/// "exists but belongs to someone else" surface as `NotFound` so the
|
||||||
/// API doesn't disclose collection existence to non-owners — the
|
/// API doesn't disclose collection existence to non-owners — the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod genres;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod mangas;
|
pub mod mangas;
|
||||||
|
pub mod page_tags;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.merge(tags::routes())
|
.merge(tags::routes())
|
||||||
.merge(authors::routes())
|
.merge(authors::routes())
|
||||||
.merge(collections::routes())
|
.merge(collections::routes())
|
||||||
|
.merge(page_tags::routes())
|
||||||
.merge(history::routes())
|
.merge(history::routes())
|
||||||
.merge(admin::routes())
|
.merge(admin::routes())
|
||||||
}
|
}
|
||||||
|
|||||||
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>,
|
pub sample_covers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row returned by `GET /collections/:id/pages`. Joins through
|
||||||
|
/// `chapters` and `mangas` so the collection detail view can render a
|
||||||
|
/// thumbnail + breadcrumb without per-row follow-up fetches.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct CollectionPageItem {
|
||||||
|
pub page_id: Uuid,
|
||||||
|
pub chapter_id: Uuid,
|
||||||
|
pub manga_id: Uuid,
|
||||||
|
pub page_number: i32,
|
||||||
|
pub chapter_number: i32,
|
||||||
|
pub chapter_title: Option<String>,
|
||||||
|
pub manga_title: String,
|
||||||
|
pub storage_key: String,
|
||||||
|
pub added_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct NewCollection {
|
pub struct NewCollection {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod collection;
|
|||||||
pub mod genre;
|
pub mod genre;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
pub mod page_tag;
|
||||||
pub mod patch;
|
pub mod patch;
|
||||||
pub mod read_progress;
|
pub mod read_progress;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -21,10 +22,14 @@ pub use api_token::ApiToken;
|
|||||||
pub use author::{Author, AuthorRef, AuthorWithCount};
|
pub use author::{Author, AuthorRef, AuthorWithCount};
|
||||||
pub use bookmark::{Bookmark, BookmarkSummary};
|
pub use bookmark::{Bookmark, BookmarkSummary};
|
||||||
pub use chapter::Chapter;
|
pub use chapter::Chapter;
|
||||||
pub use collection::{Collection, CollectionSummary};
|
pub use collection::{Collection, CollectionPageItem, CollectionSummary};
|
||||||
pub use genre::{Genre, GenreRef};
|
pub use genre::{Genre, GenreRef};
|
||||||
pub use manga::{Manga, MangaCard, MangaDetail};
|
pub use manga::{Manga, MangaCard, MangaDetail};
|
||||||
pub use page::Page;
|
pub use page::Page;
|
||||||
|
pub use page_tag::{
|
||||||
|
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
|
||||||
|
TaggedPageItem,
|
||||||
|
};
|
||||||
pub use patch::Patch;
|
pub use patch::Patch;
|
||||||
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary};
|
||||||
pub use session::Session;
|
pub use session::Session;
|
||||||
|
|||||||
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,
|
message: String,
|
||||||
details: serde_json::Value,
|
details: serde_json::Value,
|
||||||
},
|
},
|
||||||
|
/// 501 — the wire shape is accepted but the feature isn't built yet.
|
||||||
|
/// Carries a `&'static str` snake_case code so clients can detect
|
||||||
|
/// the specific pending feature (`text_search_not_yet_supported`,
|
||||||
|
/// etc.) without parsing the message. Used today by the `?text=`
|
||||||
|
/// reservation on the page-tag aggregation endpoints.
|
||||||
|
#[error("not implemented: {code}")]
|
||||||
|
NotImplemented {
|
||||||
|
code: &'static str,
|
||||||
|
message: &'static str,
|
||||||
|
},
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Database(#[from] sqlx::Error),
|
Database(#[from] sqlx::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -64,6 +74,7 @@ impl AppError {
|
|||||||
AppError::ServiceUnavailable(_) => "service_unavailable",
|
AppError::ServiceUnavailable(_) => "service_unavailable",
|
||||||
AppError::TooManyRequests { .. } => "too_many_requests",
|
AppError::TooManyRequests { .. } => "too_many_requests",
|
||||||
AppError::ValidationFailed { .. } => "validation_failed",
|
AppError::ValidationFailed { .. } => "validation_failed",
|
||||||
|
AppError::NotImplemented { code, .. } => code,
|
||||||
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
|
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
|
||||||
AppError::Database(_) => "internal_error",
|
AppError::Database(_) => "internal_error",
|
||||||
AppError::Storage(StorageError::NotFound) => "not_found",
|
AppError::Storage(StorageError::NotFound) => "not_found",
|
||||||
@@ -124,6 +135,11 @@ impl IntoResponse for AppError {
|
|||||||
message.clone(),
|
message.clone(),
|
||||||
Some(details.clone()),
|
Some(details.clone()),
|
||||||
),
|
),
|
||||||
|
AppError::NotImplemented { message, .. } => (
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
(*message).to_string(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
AppError::Database(sqlx::Error::RowNotFound) => {
|
AppError::Database(sqlx::Error::RowNotFound) => {
|
||||||
(StatusCode::NOT_FOUND, "not found".to_string(), None)
|
(StatusCode::NOT_FOUND, "not found".to_string(), None)
|
||||||
}
|
}
|
||||||
@@ -180,5 +196,15 @@ mod tests {
|
|||||||
assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
|
assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
|
||||||
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
|
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
|
||||||
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
|
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
|
||||||
|
// NotImplemented carries the code through so each pending
|
||||||
|
// feature gets its own stable identifier on the wire.
|
||||||
|
assert_eq!(
|
||||||
|
AppError::NotImplemented {
|
||||||
|
code: "text_search_not_yet_supported",
|
||||||
|
message: "x"
|
||||||
|
}
|
||||||
|
.code(),
|
||||||
|
"text_search_not_yet_supported"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::domain::collection::{Collection, CollectionSummary};
|
use crate::domain::collection::{Collection, CollectionPageItem, CollectionSummary};
|
||||||
use crate::domain::manga::Manga;
|
use crate::domain::manga::Manga;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
@@ -278,3 +278,132 @@ pub async fn list_collections_containing(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a page to a collection. Same `(true → 201, false → 200)`
|
||||||
|
/// idempotency contract as `add_manga`. FK violations (page deleted
|
||||||
|
/// between the handler's existence check and this insert) surface as
|
||||||
|
/// `NotFound`, not a 500.
|
||||||
|
pub async fn add_page(
|
||||||
|
pool: &PgPool,
|
||||||
|
collection_id: Uuid,
|
||||||
|
page_id: Uuid,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let inserted = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO collection_pages (collection_id, page_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(collection_id)
|
||||||
|
.bind(page_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
|
||||||
|
AppError::NotFound
|
||||||
|
}
|
||||||
|
other => AppError::Database(other),
|
||||||
|
})?;
|
||||||
|
let rows_affected = inserted.rows_affected();
|
||||||
|
if rows_affected > 0 {
|
||||||
|
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
|
||||||
|
.bind(collection_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(rows_affected > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_page(
|
||||||
|
pool: &PgPool,
|
||||||
|
collection_id: Uuid,
|
||||||
|
page_id: Uuid,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let rows_affected = sqlx::query(
|
||||||
|
"DELETE FROM collection_pages WHERE collection_id = $1 AND page_id = $2",
|
||||||
|
)
|
||||||
|
.bind(collection_id)
|
||||||
|
.bind(page_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if rows_affected > 0 {
|
||||||
|
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
|
||||||
|
.bind(collection_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paged list of `collection_id`'s pages, JOINed through chapters and
|
||||||
|
/// mangas so each row carries the breadcrumb the detail view needs.
|
||||||
|
pub async fn list_pages(
|
||||||
|
pool: &PgPool,
|
||||||
|
collection_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> AppResult<(Vec<CollectionPageItem>, i64)> {
|
||||||
|
let rows = sqlx::query_as::<_, CollectionPageItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id AS page_id,
|
||||||
|
p.chapter_id AS chapter_id,
|
||||||
|
ch.manga_id AS manga_id,
|
||||||
|
p.page_number AS page_number,
|
||||||
|
ch.number AS chapter_number,
|
||||||
|
ch.title AS chapter_title,
|
||||||
|
m.title AS manga_title,
|
||||||
|
p.storage_key AS storage_key,
|
||||||
|
cp.added_at AS added_at
|
||||||
|
FROM collection_pages cp
|
||||||
|
JOIN pages p ON p.id = cp.page_id
|
||||||
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
|
JOIN mangas m ON m.id = ch.manga_id
|
||||||
|
WHERE cp.collection_id = $1
|
||||||
|
ORDER BY cp.added_at DESC, p.id
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(collection_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let (total,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT count(*) FROM collection_pages WHERE collection_id = $1")
|
||||||
|
.bind(collection_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok((rows, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of `user_id`'s collections currently contain `page_id`?
|
||||||
|
/// Powers the reader context menu's "In N collections" line and the
|
||||||
|
/// "Add to collection" pre-check.
|
||||||
|
pub async fn list_collections_containing_page(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
page_id: Uuid,
|
||||||
|
) -> AppResult<Vec<Uuid>> {
|
||||||
|
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT c.id
|
||||||
|
FROM collections c
|
||||||
|
JOIN collection_pages cp ON cp.collection_id = c.id
|
||||||
|
WHERE c.user_id = $1
|
||||||
|
AND cp.page_id = $2
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(page_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod crawler;
|
|||||||
pub mod genre;
|
pub mod genre;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
pub mod page_tag;
|
||||||
pub mod read_progress;
|
pub mod read_progress;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
|||||||
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
|
// Catalog returns a single card whose href points at MID so the
|
||||||
// SPA-click walk lands on the manga we've mocked downstream.
|
// SPA-click walk lands on the manga we've mocked downstream.
|
||||||
await page.route('**/api/v1/mangas', (r) =>
|
// `**/api/v1/mangas` alone doesn't intercept query strings
|
||||||
|
// (`?limit=&sort=`), so the catalog request would fall through
|
||||||
|
// to a real backend if one is running on :8080 and seed the
|
||||||
|
// page with real-manga IDs that don't match the mock body.
|
||||||
|
// `**/api/v1/mangas?**` (and the same suffix on the catch-all
|
||||||
|
// below) makes the intercept query-tolerant.
|
||||||
|
await page.route('**/api/v1/mangas?**', (r) =>
|
||||||
r.fulfill({
|
r.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
@@ -163,4 +169,56 @@ test('phone viewport: reader back pops history (does not push), then detail back
|
|||||||
await page.waitForURL((url) => url.pathname === '/');
|
await page.waitForURL((url) => url.pathname === '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reader cover+title pushes detail when arrived from a non-detail page', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// The smart-cover-title fix: if the user got to the reader from
|
||||||
|
// a page OTHER than this manga's detail (search, library,
|
||||||
|
// direct link, etc.), the cover+title should push the detail
|
||||||
|
// page so browser-back returns to the reader. The arrow stays
|
||||||
|
// a pure "go back".
|
||||||
|
//
|
||||||
|
// We simulate "non-detail arrival" with a deep-link load
|
||||||
|
// straight to the reader. That covers cold-tab + shared-link +
|
||||||
|
// search-result cases — afterNavigate fires with from=null and
|
||||||
|
// lastInternalPath stays null, so the cover+title click sees
|
||||||
|
// a mismatch and pushes the detail page.
|
||||||
|
await mockApis(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
|
await page.goto(`/manga/${MID}/chapter/${CID}`);
|
||||||
|
await page.waitForSelector('[data-testid="back-to-manga"]');
|
||||||
|
const lenAtReader = await page.evaluate(() => window.history.length);
|
||||||
|
|
||||||
|
await page.getByTestId('back-to-manga').click();
|
||||||
|
await page.waitForURL(/\/manga\/[^/]+$/);
|
||||||
|
const lenAfter = await page.evaluate(() => window.history.length);
|
||||||
|
// PUSH (not pop) — lenAtReader + 1.
|
||||||
|
expect(lenAfter).toBe(lenAtReader + 1);
|
||||||
|
|
||||||
|
// Browser-back from detail returns to reader (proves it was a
|
||||||
|
// push, not a replace).
|
||||||
|
await page.goBack();
|
||||||
|
await page.waitForURL(/\/chapter\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reader arrow always pops history', async ({ page }) => {
|
||||||
|
await mockApis(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-testid="manga-list"] a').first().click();
|
||||||
|
await page.waitForURL(/\/manga\/[^/]+$/);
|
||||||
|
await page.locator('[data-testid="chapter-list"] a').first().click();
|
||||||
|
await page.waitForURL(/\/chapter\//);
|
||||||
|
|
||||||
|
const lenAtReader = await page.evaluate(() => window.history.length);
|
||||||
|
|
||||||
|
// Click the arrow — always pops, regardless of previous page.
|
||||||
|
await page.getByTestId('reader-back-arrow').click();
|
||||||
|
await page.waitForURL(/\/manga\/[^/]+$/);
|
||||||
|
const lenAfter = await page.evaluate(() => window.history.length);
|
||||||
|
expect(lenAfter).toBe(lenAtReader);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
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",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.60.2",
|
"version": "0.62.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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,
|
removeMangaFromCollection,
|
||||||
type CollectionSummary
|
type CollectionSummary
|
||||||
} from '$lib/api/collections';
|
} from '$lib/api/collections';
|
||||||
|
import {
|
||||||
|
addPageToCollection,
|
||||||
|
getMyCollectionsContainingPage,
|
||||||
|
removePageFromCollection
|
||||||
|
} from '$lib/api/page_collections';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated-union target so the same modal serves both the
|
||||||
|
* manga-detail "Add to collection" flow and the reader's page
|
||||||
|
* context menu. The list / pre-check / toggle / create-and-add
|
||||||
|
* scaffolding is identical for both — only the leaf API call
|
||||||
|
* differs, dispatched through `loadContaining` / `addTo` /
|
||||||
|
* `removeFrom` below.
|
||||||
|
*/
|
||||||
|
export type Target =
|
||||||
|
| { kind: 'manga'; id: string }
|
||||||
|
| { kind: 'page'; id: string };
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open,
|
open,
|
||||||
mangaId,
|
target,
|
||||||
onClose
|
onClose
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
mangaId: string;
|
target: Target;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -28,23 +45,42 @@
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
// Refetch every time the modal opens (and when the manga id changes
|
// Refetch on open and whenever the target changes (e.g., the user
|
||||||
// mid-session — unlikely but cheap). The data is per-user and per-
|
// right-clicks a different page while the modal is in flight). The
|
||||||
// manga, so re-fetching is the simplest way to stay in sync with
|
// dependency on `target.id` is explicit so the $effect re-runs.
|
||||||
// changes made elsewhere (e.g., a collection deleted on another page).
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
// Touching target.id wires the reactive dependency.
|
||||||
|
void target.id;
|
||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadContaining(t: Target): Promise<string[]> {
|
||||||
|
return t.kind === 'manga'
|
||||||
|
? getMyCollectionsContaining(t.id)
|
||||||
|
: getMyCollectionsContainingPage(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTo(collectionId: string, t: Target): Promise<void> {
|
||||||
|
return t.kind === 'manga'
|
||||||
|
? addMangaToCollection(collectionId, t.id)
|
||||||
|
: addPageToCollection(collectionId, t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFrom(collectionId: string, t: Target): Promise<void> {
|
||||||
|
return t.kind === 'manga'
|
||||||
|
? removeMangaFromCollection(collectionId, t.id)
|
||||||
|
: removePageFromCollection(collectionId, t.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const [page, ids] = await Promise.all([
|
const [page, ids] = await Promise.all([
|
||||||
listMyCollections({ limit: 200 }),
|
listMyCollections({ limit: 200 }),
|
||||||
getMyCollectionsContaining(mangaId)
|
loadContaining(target)
|
||||||
]);
|
]);
|
||||||
collections = page.items;
|
collections = page.items;
|
||||||
containingIds = new Set(ids);
|
containingIds = new Set(ids);
|
||||||
@@ -55,9 +91,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Functional set updates that read the latest state at mutation
|
|
||||||
// time, so concurrent toggles on different rows don't clobber
|
|
||||||
// each other by building from a stale snapshot.
|
|
||||||
function withAdd<T>(s: Set<T>, v: T): Set<T> {
|
function withAdd<T>(s: Set<T>, v: T): Set<T> {
|
||||||
const n = new Set(s);
|
const n = new Set(s);
|
||||||
n.add(v);
|
n.add(v);
|
||||||
@@ -72,21 +105,27 @@
|
|||||||
async function toggle(collection: CollectionSummary) {
|
async function toggle(collection: CollectionSummary) {
|
||||||
if (busyIds.has(collection.id)) return;
|
if (busyIds.has(collection.id)) return;
|
||||||
const wasIn = containingIds.has(collection.id);
|
const wasIn = containingIds.has(collection.id);
|
||||||
// Optimistic toggle — local set first; revert on failure.
|
|
||||||
containingIds = wasIn
|
containingIds = wasIn
|
||||||
? withDelete(containingIds, collection.id)
|
? withDelete(containingIds, collection.id)
|
||||||
: withAdd(containingIds, collection.id);
|
: withAdd(containingIds, collection.id);
|
||||||
busyIds = withAdd(busyIds, collection.id);
|
busyIds = withAdd(busyIds, collection.id);
|
||||||
try {
|
try {
|
||||||
if (wasIn) {
|
if (wasIn) {
|
||||||
await removeMangaFromCollection(collection.id, mangaId);
|
await removeFrom(collection.id, target);
|
||||||
collection.manga_count = Math.max(0, collection.manga_count - 1);
|
// manga_count drifts when pages are toggled — the
|
||||||
|
// backend doesn't track page_count yet, so we only
|
||||||
|
// adjust the count for the manga path. Page targets
|
||||||
|
// leave manga_count as-is.
|
||||||
|
if (target.kind === 'manga') {
|
||||||
|
collection.manga_count = Math.max(0, collection.manga_count - 1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await addMangaToCollection(collection.id, mangaId);
|
await addTo(collection.id, target);
|
||||||
collection.manga_count += 1;
|
if (target.kind === 'manga') {
|
||||||
|
collection.manga_count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Revert (read latest containingIds, not the pre-toggle snapshot).
|
|
||||||
containingIds = wasIn
|
containingIds = wasIn
|
||||||
? withAdd(containingIds, collection.id)
|
? withAdd(containingIds, collection.id)
|
||||||
: withDelete(containingIds, collection.id);
|
: withDelete(containingIds, collection.id);
|
||||||
@@ -103,15 +142,11 @@
|
|||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const created = await createCollection({ name });
|
const created = await createCollection({ name });
|
||||||
// The list endpoint sorts by updated_at DESC; adding the
|
await addTo(created.id, target);
|
||||||
// manga immediately also bumps it. Append a synthetic
|
|
||||||
// summary so the new collection appears checked-on right
|
|
||||||
// away rather than waiting for a refetch.
|
|
||||||
await addMangaToCollection(created.id, mangaId);
|
|
||||||
collections = [
|
collections = [
|
||||||
{
|
{
|
||||||
...created,
|
...created,
|
||||||
manga_count: 1,
|
manga_count: target.kind === 'manga' ? 1 : 0,
|
||||||
sample_covers: []
|
sample_covers: []
|
||||||
},
|
},
|
||||||
...collections
|
...collections
|
||||||
@@ -156,10 +191,20 @@
|
|||||||
/>
|
/>
|
||||||
<span class="row-label">
|
<span class="row-label">
|
||||||
<span class="row-name">{c.name}</span>
|
<span class="row-name">{c.name}</span>
|
||||||
<span class="row-count">
|
{#if target.kind === 'manga'}
|
||||||
{c.manga_count}
|
<!-- The count is the manga count only.
|
||||||
{c.manga_count === 1 ? 'manga' : 'mangas'}
|
Suppress it on page targets to
|
||||||
</span>
|
avoid the rot of showing "0
|
||||||
|
mangas" beside a collection that
|
||||||
|
has 12 saved pages — the user
|
||||||
|
would read the modal as broken.
|
||||||
|
A backend page_count is a
|
||||||
|
follow-up. -->
|
||||||
|
<span class="row-count">
|
||||||
|
{c.manga_count}
|
||||||
|
{c.manga_count === 1 ? 'manga' : 'mangas'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
onPrev,
|
onPrev,
|
||||||
onNext,
|
onNext,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onLongPress,
|
||||||
testid = 'tap-zone'
|
testid = 'tap-zone'
|
||||||
}: {
|
}: {
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
/**
|
||||||
|
* Fires when a touch lingers > 450ms on any zone without
|
||||||
|
* moving. The viewport-coord `anchor` lets the reader open a
|
||||||
|
* sheet anchored to the press location. Desktop pointers
|
||||||
|
* (`pointerType !== 'touch'`) never trigger this — right-click
|
||||||
|
* is the desktop entry point and lives in the reader.
|
||||||
|
*/
|
||||||
|
onLongPress?: (anchor: { x: number; y: number }) => void;
|
||||||
testid?: string;
|
testid?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 450;
|
||||||
|
const MOVE_TOLERANCE = 8;
|
||||||
|
|
||||||
|
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pressStart: { x: number; y: number } | null = null;
|
||||||
|
// Self-expiring suppression: cleared either by an actual click
|
||||||
|
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
|
||||||
|
// timer below. The latter matters when the user lifts off the
|
||||||
|
// tap zone after a long-press (finger slid onto the sheet) — no
|
||||||
|
// click is synthesized, so without an expiry the flag would leak
|
||||||
|
// and eat the next legitimate tap.
|
||||||
|
const SUPPRESS_EXPIRY_MS = 500;
|
||||||
|
let suppressNextClick = false;
|
||||||
|
let suppressExpiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearPress() {
|
||||||
|
if (pressTimer != null) {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
pressTimer = null;
|
||||||
|
}
|
||||||
|
pressStart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSuppress() {
|
||||||
|
suppressNextClick = false;
|
||||||
|
if (suppressExpiryTimer != null) {
|
||||||
|
clearTimeout(suppressExpiryTimer);
|
||||||
|
suppressExpiryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!onLongPress) return;
|
||||||
|
if (e.pointerType !== 'touch') return;
|
||||||
|
clearPress();
|
||||||
|
pressStart = { x: e.clientX, y: e.clientY };
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
pressTimer = setTimeout(() => {
|
||||||
|
pressTimer = null;
|
||||||
|
suppressNextClick = true;
|
||||||
|
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
|
||||||
|
suppressExpiryTimer = setTimeout(() => {
|
||||||
|
suppressNextClick = false;
|
||||||
|
suppressExpiryTimer = null;
|
||||||
|
}, SUPPRESS_EXPIRY_MS);
|
||||||
|
onLongPress?.({ x: startX, y: startY });
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (pressTimer == null || pressStart == null) return;
|
||||||
|
const dx = e.clientX - pressStart.x;
|
||||||
|
const dy = e.clientY - pressStart.y;
|
||||||
|
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
clearPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel() {
|
||||||
|
clearPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
// Scrolling while pressed almost always means the user is
|
||||||
|
// panning, not deliberately holding. Cancel.
|
||||||
|
clearPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSuppress(handler: () => void) {
|
||||||
|
return () => {
|
||||||
|
if (suppressNextClick) {
|
||||||
|
clearSuppress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handler();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMount only runs on the client, so the window-scroll listener
|
||||||
|
// is automatically guarded — no SSR check needed. The teardown
|
||||||
|
// also clears any in-flight long-press timer.
|
||||||
|
onMount(() => {
|
||||||
|
if (!onLongPress) return;
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
clearPress();
|
||||||
|
clearSuppress();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tap-zones" data-testid={testid}>
|
<div class="tap-zones" data-testid={testid}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="zone left"
|
class="zone left"
|
||||||
onclick={onPrev}
|
onclick={withSuppress(onPrev)}
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerCancel}
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
data-testid="{testid}-left"
|
data-testid="{testid}-left"
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="zone center"
|
class="zone center"
|
||||||
onclick={onToggle}
|
onclick={withSuppress(onToggle)}
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerCancel}
|
||||||
aria-label="Toggle controls"
|
aria-label="Toggle controls"
|
||||||
data-testid="{testid}-center"
|
data-testid="{testid}-center"
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="zone right"
|
class="zone right"
|
||||||
onclick={onNext}
|
onclick={withSuppress(onNext)}
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerCancel}
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
data-testid="{testid}-right"
|
data-testid="{testid}-right"
|
||||||
></button>
|
></button>
|
||||||
|
|||||||
@@ -1,7 +1,48 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
import TapZone from './TapZone.svelte';
|
import TapZone from './TapZone.svelte';
|
||||||
|
|
||||||
|
// jsdom does not implement PointerEvent. Construct a MouseEvent of the
|
||||||
|
// correct type and decorate it with the `pointerType` + coords the
|
||||||
|
// component reads — Svelte forwards it to the onpointerdown handler
|
||||||
|
// unchanged, and the guard `e.pointerType === 'touch'` reads our
|
||||||
|
// property.
|
||||||
|
function pointerEvent(
|
||||||
|
type: string,
|
||||||
|
init: { pointerType?: string; clientX?: number; clientY?: number } = {}
|
||||||
|
): Event {
|
||||||
|
const ev = new MouseEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: init.clientX,
|
||||||
|
clientY: init.clientY
|
||||||
|
});
|
||||||
|
if (init.pointerType != null) {
|
||||||
|
Object.defineProperty(ev, 'pointerType', {
|
||||||
|
value: init.pointerType,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerDown(
|
||||||
|
el: Element,
|
||||||
|
init: { pointerType: string; clientX: number; clientY: number }
|
||||||
|
) {
|
||||||
|
el.dispatchEvent(pointerEvent('pointerdown', init));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerMove(
|
||||||
|
el: Element,
|
||||||
|
init: { clientX: number; clientY: number }
|
||||||
|
) {
|
||||||
|
el.dispatchEvent(pointerEvent('pointermove', init));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerUp(el: Element) {
|
||||||
|
el.dispatchEvent(pointerEvent('pointerup'));
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe('TapZone', () => {
|
describe('TapZone', () => {
|
||||||
@@ -49,4 +90,147 @@ describe('TapZone', () => {
|
|||||||
expect(screen.getByTestId('reader-tap-center')).toBeTruthy();
|
expect(screen.getByTestId('reader-tap-center')).toBeTruthy();
|
||||||
expect(screen.getByTestId('reader-tap-right')).toBeTruthy();
|
expect(screen.getByTestId('reader-tap-right')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('long-press', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onLongPress with anchor coords after 450ms touch hold', () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const onNext = vi.fn();
|
||||||
|
render(TapZone, {
|
||||||
|
props: {
|
||||||
|
onPrev: () => {},
|
||||||
|
onNext,
|
||||||
|
onToggle: () => {},
|
||||||
|
onLongPress
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const right = screen.getByTestId('tap-zone-right');
|
||||||
|
pointerDown(right, {
|
||||||
|
pointerType: 'touch',
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 300
|
||||||
|
});
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(onLongPress).toHaveBeenCalledWith({ x: 200, y: 300 });
|
||||||
|
// The synthesized click that follows the long-press is
|
||||||
|
// suppressed so the next-page handler doesn't fire.
|
||||||
|
right.click();
|
||||||
|
expect(onNext).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire onLongPress for mouse pointers', () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
render(TapZone, {
|
||||||
|
props: {
|
||||||
|
onPrev: () => {},
|
||||||
|
onNext: () => {},
|
||||||
|
onToggle: () => {},
|
||||||
|
onLongPress
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const center = screen.getByTestId('tap-zone-center');
|
||||||
|
pointerDown(center, {
|
||||||
|
pointerType: 'mouse',
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100
|
||||||
|
});
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels long-press when pointer moves beyond tolerance', () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
render(TapZone, {
|
||||||
|
props: {
|
||||||
|
onPrev: () => {},
|
||||||
|
onNext: () => {},
|
||||||
|
onToggle: () => {},
|
||||||
|
onLongPress
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const right = screen.getByTestId('tap-zone-right');
|
||||||
|
pointerDown(right, {
|
||||||
|
pointerType: 'touch',
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100
|
||||||
|
});
|
||||||
|
pointerMove(right, { clientX: 200, clientY: 100 });
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels long-press on pointer up before threshold', () => {
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
render(TapZone, {
|
||||||
|
props: {
|
||||||
|
onPrev: () => {},
|
||||||
|
onNext: () => {},
|
||||||
|
onToggle: () => {},
|
||||||
|
onLongPress
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const center = screen.getByTestId('tap-zone-center');
|
||||||
|
pointerDown(center, {
|
||||||
|
pointerType: 'touch',
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100
|
||||||
|
});
|
||||||
|
pointerUp(center);
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(onLongPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regular click still fires onNext when no long-press handler', () => {
|
||||||
|
const onNext = vi.fn();
|
||||||
|
render(TapZone, {
|
||||||
|
props: {
|
||||||
|
onPrev: () => {},
|
||||||
|
onNext,
|
||||||
|
onToggle: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
screen.getByTestId('tap-zone-right').click();
|
||||||
|
expect(onNext).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppression auto-expires so the next legitimate tap fires after a slide-off long-press', () => {
|
||||||
|
// Scenario: user long-presses, finger slides off the zone
|
||||||
|
// onto the opened sheet, releases there. No click is
|
||||||
|
// synthesized on the zone, so without the expiry the
|
||||||
|
// suppression would leak and eat the next tap. The 500ms
|
||||||
|
// expiry inside the long-press callback caps the
|
||||||
|
// suppression window.
|
||||||
|
const onLongPress = vi.fn();
|
||||||
|
const onNext = vi.fn();
|
||||||
|
render(TapZone, {
|
||||||
|
props: {
|
||||||
|
onPrev: () => {},
|
||||||
|
onNext,
|
||||||
|
onToggle: () => {},
|
||||||
|
onLongPress
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const right = screen.getByTestId('tap-zone-right');
|
||||||
|
pointerDown(right, {
|
||||||
|
pointerType: 'touch',
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100
|
||||||
|
});
|
||||||
|
// Long-press fires.
|
||||||
|
vi.advanceTimersByTime(450);
|
||||||
|
expect(onLongPress).toHaveBeenCalledOnce();
|
||||||
|
// Drain the suppression-expiry window without a click —
|
||||||
|
// simulates the user sliding off and releasing elsewhere.
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
// Fresh tap on the zone — must NOT be eaten.
|
||||||
|
right.click();
|
||||||
|
expect(onNext).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
@@ -402,7 +403,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<h1>Mangas</h1>
|
<div class="heading-row">
|
||||||
|
<h1>Mangas</h1>
|
||||||
|
<!--
|
||||||
|
Secondary entry point into the per-page search at /search,
|
||||||
|
which is headed "Page search". Label matches the destination's
|
||||||
|
title so the two read as one feature. It filters the user's
|
||||||
|
tagged pages and pivots between Pages / Chapters / Mangas;
|
||||||
|
title-search above stays the primary action. The name is also
|
||||||
|
forward-compatible with the planned OCR text search (which
|
||||||
|
searches page content, not just tags).
|
||||||
|
-->
|
||||||
|
<a class="alt-search" href="/search" data-testid="nav-page-search">
|
||||||
|
<span>Page search</span>
|
||||||
|
<ArrowRight size={14} aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onsubmit={onSubmit}
|
onsubmit={onSubmit}
|
||||||
@@ -562,11 +578,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.heading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-row h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-search {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-search:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
removeMangaFromCollection,
|
removeMangaFromCollection,
|
||||||
updateCollection
|
updateCollection
|
||||||
} from '$lib/api/collections';
|
} from '$lib/api/collections';
|
||||||
|
import {
|
||||||
|
removePageFromCollection,
|
||||||
|
type CollectionPageItem
|
||||||
|
} from '$lib/api/page_collections';
|
||||||
import type { Manga } from '$lib/api/client';
|
import type { Manga } from '$lib/api/client';
|
||||||
|
import { fileUrl } from '$lib/api/client';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
@@ -18,6 +23,8 @@
|
|||||||
let collection = $state({ ...data.collection });
|
let collection = $state({ ...data.collection });
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let mangas = $state<Manga[]>([...data.mangas]);
|
let mangas = $state<Manga[]>([...data.mangas]);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let pages = $state<CollectionPageItem[]>([...data.pages]);
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editName = $state('');
|
let editName = $state('');
|
||||||
@@ -72,6 +79,17 @@
|
|||||||
editError = (e as Error).message;
|
editError = (e as Error).message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onRemovePage(p: CollectionPageItem) {
|
||||||
|
const snapshot = pages;
|
||||||
|
pages = pages.filter((x) => x.page_id !== p.page_id);
|
||||||
|
try {
|
||||||
|
await removePageFromCollection(collection.id, p.page_id);
|
||||||
|
} catch (e) {
|
||||||
|
pages = snapshot;
|
||||||
|
editError = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -162,28 +180,73 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if mangas.length === 0}
|
{#if mangas.length === 0 && pages.length === 0}
|
||||||
<p class="status" data-testid="collection-empty">
|
<p class="status" data-testid="collection-empty">
|
||||||
This collection is empty.
|
This collection is empty.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{/if}
|
||||||
<ul class="manga-grid" data-testid="collection-manga-list">
|
|
||||||
{#each mangas as m (m.id)}
|
{#if mangas.length > 0}
|
||||||
<li class="card-with-remove">
|
<section aria-labelledby="mangas-heading">
|
||||||
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
|
||||||
<button
|
<ul class="manga-grid" data-testid="collection-manga-list">
|
||||||
type="button"
|
{#each mangas as m (m.id)}
|
||||||
class="remove"
|
<li class="card-with-remove">
|
||||||
onclick={() => onRemoveManga(m)}
|
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
||||||
aria-label={`Remove ${m.title} from collection`}
|
<button
|
||||||
title="Remove from collection"
|
type="button"
|
||||||
data-testid={`collection-remove-manga-${m.id}`}
|
class="remove"
|
||||||
>
|
onclick={() => onRemoveManga(m)}
|
||||||
<X size={14} aria-hidden="true" />
|
aria-label={`Remove ${m.title} from collection`}
|
||||||
</button>
|
title="Remove from collection"
|
||||||
</li>
|
data-testid={`collection-remove-manga-${m.id}`}
|
||||||
{/each}
|
>
|
||||||
</ul>
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pages.length > 0}
|
||||||
|
<section aria-labelledby="pages-heading">
|
||||||
|
<h2 id="pages-heading" class="section-heading">Pages</h2>
|
||||||
|
<ul class="page-grid" data-testid="collection-page-list">
|
||||||
|
{#each pages as p (p.page_id)}
|
||||||
|
<li class="card-with-remove">
|
||||||
|
<a
|
||||||
|
class="page-card"
|
||||||
|
href={`/manga/${p.manga_id}/chapter/${p.chapter_id}?page=${p.page_number}`}
|
||||||
|
data-testid={`collection-page-${p.page_id}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={fileUrl(p.storage_key)}
|
||||||
|
alt={`${p.manga_title} chapter ${p.chapter_number} page ${p.page_number}`}
|
||||||
|
class="page-thumb"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span class="page-meta">
|
||||||
|
<span class="page-title">{p.manga_title}</span>
|
||||||
|
<span class="page-breadcrumb">
|
||||||
|
Ch. {p.chapter_number} · page {p.page_number}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove"
|
||||||
|
onclick={() => onRemovePage(p)}
|
||||||
|
aria-label={`Remove ${p.manga_title} page ${p.page_number} from collection`}
|
||||||
|
title="Remove from collection"
|
||||||
|
data-testid={`collection-remove-page-${p.page_id}`}
|
||||||
|
>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -260,6 +323,67 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin: var(--space-5) 0 var(--space-3);
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-grid {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-breadcrumb {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.card-with-remove {
|
.card-with-remove {
|
||||||
position: relative;
|
position: relative;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -4,33 +4,30 @@ import {
|
|||||||
getCollection,
|
getCollection,
|
||||||
listCollectionMangas
|
listCollectionMangas
|
||||||
} from '$lib/api/collections';
|
} from '$lib/api/collections';
|
||||||
|
import { listCollectionPages } from '$lib/api/page_collections';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, url }) => {
|
export const load: PageLoad = async ({ params, url }) => {
|
||||||
try {
|
try {
|
||||||
const [collection, mangas] = await Promise.all([
|
const [collection, mangas, pages] = await Promise.all([
|
||||||
getCollection(params.id),
|
getCollection(params.id),
|
||||||
listCollectionMangas(params.id, { limit: 200 })
|
listCollectionMangas(params.id, { limit: 200 }),
|
||||||
|
listCollectionPages(params.id, { limit: 200 })
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
collection,
|
collection,
|
||||||
mangas: mangas.items,
|
mangas: mangas.items,
|
||||||
total: mangas.page.total
|
total: mangas.page.total,
|
||||||
|
pages: pages.items
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
// 401 means the user's session is gone — bounce to login
|
|
||||||
// and preserve where they wanted to go.
|
|
||||||
if (e.status === 401) {
|
if (e.status === 401) {
|
||||||
const next = encodeURIComponent(url.pathname);
|
const next = encodeURIComponent(url.pathname);
|
||||||
redirect(302, `/login?next=${next}`);
|
redirect(302, `/login?next=${next}`);
|
||||||
}
|
}
|
||||||
// 403 (post-Phase-3-polish the backend collapses this to
|
|
||||||
// 404 already, but keep the branch for defense-in-depth)
|
|
||||||
// and 404 both render the standard not-found page so the
|
|
||||||
// URL doesn't disclose collection existence to non-owners.
|
|
||||||
if (e.status === 404 || e.status === 403) {
|
if (e.status === 404 || e.status === 403) {
|
||||||
error(404, 'Collection not found');
|
error(404, 'Collection not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,16 @@
|
|||||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||||
|
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||||
import BookImage from '@lucide/svelte/icons/book-image';
|
import BookImage from '@lucide/svelte/icons/book-image';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
type Tab = 'bookmarks' | 'collections' | 'history';
|
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||||
const TABS: { label: string; value: Tab }[] = [
|
const TABS: { label: string; value: Tab }[] = [
|
||||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||||
{ label: 'Collections', value: 'collections' },
|
{ label: 'Collections', value: 'collections' },
|
||||||
|
{ label: 'Page tags', value: 'page-tags' },
|
||||||
{ label: 'History', value: 'history' }
|
{ label: 'History', value: 'history' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
// default so visiting /library bare doesn't add noise to the URL.
|
// default so visiting /library bare doesn't add noise to the URL.
|
||||||
const activeTab: Tab = $derived.by(() => {
|
const activeTab: Tab = $derived.by(() => {
|
||||||
const t = $page.url.searchParams.get('tab');
|
const t = $page.url.searchParams.get('tab');
|
||||||
return t === 'collections' || t === 'history' ? t : 'bookmarks';
|
if (t === 'collections' || t === 'history' || t === 'page-tags') return t;
|
||||||
|
return 'bookmarks';
|
||||||
});
|
});
|
||||||
|
|
||||||
function setTab(t: Tab) {
|
function setTab(t: Tab) {
|
||||||
@@ -34,6 +37,8 @@
|
|||||||
const url = new URL($page.url);
|
const url = new URL($page.url);
|
||||||
if (t === 'bookmarks') url.searchParams.delete('tab');
|
if (t === 'bookmarks') url.searchParams.delete('tab');
|
||||||
else url.searchParams.set('tab', t);
|
else url.searchParams.set('tab', t);
|
||||||
|
// Cast away the literal union — the SegmentedControl is
|
||||||
|
// generic so its onchange receives `string`.
|
||||||
void goto(url.toString(), {
|
void goto(url.toString(), {
|
||||||
replaceState: true,
|
replaceState: true,
|
||||||
keepFocus: true,
|
keepFocus: true,
|
||||||
@@ -81,6 +86,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<CollectionsGrid collections={data.collections} />
|
<CollectionsGrid collections={data.collections} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if activeTab === 'page-tags'}
|
||||||
|
<PageTagsList
|
||||||
|
initialItems={data.pageTags}
|
||||||
|
initialDistinct={data.distinctPageTags}
|
||||||
|
/>
|
||||||
{:else if data.history.length === 0}
|
{:else if data.history.length === 0}
|
||||||
<p class="hint" data-testid="library-history-empty">
|
<p class="hint" data-testid="library-history-empty">
|
||||||
Nothing here yet — open any manga and a row will land here once you turn
|
Nothing here yet — open any manga and a row will land here once you turn
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ import { ApiError } from '$lib/api/client';
|
|||||||
import { listMyBookmarks } from '$lib/api/bookmarks';
|
import { listMyBookmarks } from '$lib/api/bookmarks';
|
||||||
import { listMyCollections } from '$lib/api/collections';
|
import { listMyCollections } from '$lib/api/collections';
|
||||||
import { listMyReadProgress } from '$lib/api/read_progress';
|
import { listMyReadProgress } from '$lib/api/read_progress';
|
||||||
|
import {
|
||||||
|
listMyPageTags,
|
||||||
|
listMyDistinctPageTags
|
||||||
|
} from '$lib/api/page_tags';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads bookmarks + collections + reading-history in one shot so the
|
* Loads bookmarks + collections + history + page-tags in one shot so
|
||||||
* Library segmented control can swap between sub-tabs without firing a
|
* the Library segmented control can swap between sub-tabs without
|
||||||
* second round trip per tap. 401 → unauthenticated path; the page
|
* firing a second round trip per tap. 401 → unauthenticated path; the
|
||||||
* surfaces a sign-in prompt and renders empty lists.
|
* page surfaces a sign-in prompt and renders empty lists.
|
||||||
*/
|
*/
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
const empty = {
|
const empty = {
|
||||||
@@ -18,19 +22,26 @@ export const load: PageLoad = async () => {
|
|||||||
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
|
bookmarks: [] as Awaited<ReturnType<typeof listMyBookmarks>>['items'],
|
||||||
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
|
collections: [] as Awaited<ReturnType<typeof listMyCollections>>['items'],
|
||||||
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'],
|
history: [] as Awaited<ReturnType<typeof listMyReadProgress>>['items'],
|
||||||
|
pageTags: [] as Awaited<ReturnType<typeof listMyPageTags>>['items'],
|
||||||
|
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const [bookmarks, collections, history] = await Promise.all([
|
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||||
listMyBookmarks(),
|
await Promise.all([
|
||||||
listMyCollections({ limit: 200 }),
|
listMyBookmarks(),
|
||||||
listMyReadProgress({ limit: 100 })
|
listMyCollections({ limit: 200 }),
|
||||||
]);
|
listMyReadProgress({ limit: 100 }),
|
||||||
|
listMyPageTags({ limit: 100 }),
|
||||||
|
listMyDistinctPageTags(undefined, 100)
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
...empty,
|
...empty,
|
||||||
bookmarks: bookmarks.items,
|
bookmarks: bookmarks.items,
|
||||||
collections: collections.items,
|
collections: collections.items,
|
||||||
history: history.items
|
history: history.items,
|
||||||
|
pageTags: pageTags.items,
|
||||||
|
distinctPageTags
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
|||||||
@@ -567,7 +567,7 @@
|
|||||||
{#if session.user}
|
{#if session.user}
|
||||||
<AddToCollectionModal
|
<AddToCollectionModal
|
||||||
open={collectionModalOpen}
|
open={collectionModalOpen}
|
||||||
mangaId={manga.id}
|
target={{ kind: 'manga', id: manga.id }}
|
||||||
onClose={() => (collectionModalOpen = false)}
|
onClose={() => (collectionModalOpen = false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
|
||||||
import { fileUrl, ApiError } from '$lib/api/client';
|
import { fileUrl, ApiError } from '$lib/api/client';
|
||||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||||
import { preferences } from '$lib/preferences.svelte';
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
@@ -11,8 +11,18 @@
|
|||||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Sheet from '$lib/components/Sheet.svelte';
|
import Sheet from '$lib/components/Sheet.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
import TapZone from '$lib/components/TapZone.svelte';
|
import TapZone from '$lib/components/TapZone.svelte';
|
||||||
|
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
|
||||||
|
import AddTagsSheet from '$lib/components/AddTagsSheet.svelte';
|
||||||
|
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||||
|
import { getMyCollectionsContainingPage } from '$lib/api/page_collections';
|
||||||
|
import { getMyTagsForPage } from '$lib/api/page_tags';
|
||||||
|
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||||
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
import Link2 from '@lucide/svelte/icons/link-2';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
@@ -58,21 +68,26 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Seed the initial page index from `?page=`. Numeric values are
|
// Initial page index from `?page=`. Numeric values are 1-indexed
|
||||||
// 1-indexed and clamped to the chapter's page count; the sentinel
|
// and clamped to the chapter's page count; the sentinel `last`
|
||||||
// `last` lands on the final page (used by the prev-chapter chevron
|
// lands on the final page (used by the prev-chapter chevron when
|
||||||
// when going backwards through the series). Component remounts on
|
// going backwards through the series).
|
||||||
// chapter navigation, so this only runs at the start of each
|
//
|
||||||
// chapter — referencing `data` here is intentional.
|
// `$derived` so it tracks `data.requestedPage` / `data.pages`
|
||||||
// svelte-ignore state_referenced_locally
|
// across chapter navigation — SvelteKit reuses the component on
|
||||||
const initialIndex = (() => {
|
// same-route nav (the chevrons at `jumpToPrevChapter` /
|
||||||
|
// `jumpToNextChapter` below call `goto(...)` to navigate between
|
||||||
|
// chapters), and a `const` would freeze initialIndex at the
|
||||||
|
// first chapter's value.
|
||||||
|
const initialIndex = $derived.by(() => {
|
||||||
const req = data.requestedPage;
|
const req = data.requestedPage;
|
||||||
if (req === 'last') return Math.max(0, data.pages.length - 1);
|
if (req === 'last') return Math.max(0, data.pages.length - 1);
|
||||||
if (typeof req === 'number') {
|
if (typeof req === 'number') {
|
||||||
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
|
return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
})();
|
});
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let index = $state(initialIndex);
|
let index = $state(initialIndex);
|
||||||
let continuousPageEls: HTMLImageElement[] = $state([]);
|
let continuousPageEls: HTMLImageElement[] = $state([]);
|
||||||
let chapterBarEl: HTMLElement | undefined = $state();
|
let chapterBarEl: HTMLElement | undefined = $state();
|
||||||
@@ -88,6 +103,217 @@
|
|||||||
let chapterJumpOpen = $state(false);
|
let chapterJumpOpen = $state(false);
|
||||||
let settingsOpen = $state(false);
|
let settingsOpen = $state(false);
|
||||||
|
|
||||||
|
// ---- Page context menu / tag + collection actions ----
|
||||||
|
//
|
||||||
|
// Desktop: right-click on a `.page-image` opens the floating
|
||||||
|
// `PageContextMenu`. Mobile: long-press (via TapZone in single
|
||||||
|
// mode, or per-image timer in continuous mode) opens an action
|
||||||
|
// sheet that funnels into the same modals. Unauthenticated users
|
||||||
|
// see neither — there's nothing for them to act on.
|
||||||
|
let contextMenuOpen = $state(false);
|
||||||
|
let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
|
let activePageId = $state<string | null>(null);
|
||||||
|
let activePageCollectionCount = $state<number | null>(null);
|
||||||
|
let activePageTags = $state<string[]>([]);
|
||||||
|
let actionSheetOpen = $state(false);
|
||||||
|
let collectionsModalOpen = $state(false);
|
||||||
|
let tagsModalOpen = $state(false);
|
||||||
|
|
||||||
|
// Monotonically-increasing token so a slow loadPageSummary for
|
||||||
|
// page A can't clobber a fresh load for page B. The user right-
|
||||||
|
// clicking one image then another on a slow network is the
|
||||||
|
// scenario; without the token the older request's resolution
|
||||||
|
// wins because it lands last.
|
||||||
|
let loadPageSummaryToken = 0;
|
||||||
|
|
||||||
|
async function loadPageSummary(pageId: string) {
|
||||||
|
const token = ++loadPageSummaryToken;
|
||||||
|
activePageCollectionCount = null;
|
||||||
|
activePageTags = [];
|
||||||
|
try {
|
||||||
|
const [ids, tags] = await Promise.all([
|
||||||
|
getMyCollectionsContainingPage(pageId),
|
||||||
|
getMyTagsForPage(pageId)
|
||||||
|
]);
|
||||||
|
if (token !== loadPageSummaryToken) return;
|
||||||
|
activePageCollectionCount = ids.length;
|
||||||
|
activePageTags = tags;
|
||||||
|
} catch {
|
||||||
|
// Soft-fail — context lines just stay empty / loading.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContextMenu(anchor: { x: number; y: number }, pageId: string) {
|
||||||
|
if (!session.user) return;
|
||||||
|
activePageId = pageId;
|
||||||
|
contextMenuAnchor = anchor;
|
||||||
|
contextMenuOpen = true;
|
||||||
|
void loadPageSummary(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openActionSheet(pageId: string) {
|
||||||
|
if (!session.user) return;
|
||||||
|
activePageId = pageId;
|
||||||
|
actionSheetOpen = true;
|
||||||
|
void loadPageSummary(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageContextMenu(e: MouseEvent, pageId: string) {
|
||||||
|
if (!session.user) return;
|
||||||
|
// Power-user escape hatch — Shift + right-click drops to the
|
||||||
|
// native browser context menu (image save, copy, inspect).
|
||||||
|
if (e.shiftKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openContextMenu({ x: e.clientX, y: e.clientY }, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddToCollection() {
|
||||||
|
contextMenuOpen = false;
|
||||||
|
actionSheetOpen = false;
|
||||||
|
collectionsModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddTag() {
|
||||||
|
contextMenuOpen = false;
|
||||||
|
actionSheetOpen = false;
|
||||||
|
tagsModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePageNumber(): number | null {
|
||||||
|
if (!activePageId) return null;
|
||||||
|
const i = pages.findIndex((p) => p.id === activePageId);
|
||||||
|
return i >= 0 ? i + 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeStorageKey(): string | null {
|
||||||
|
if (!activePageId) return null;
|
||||||
|
return pages.find((p) => p.id === activePageId)?.storage_key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveImage() {
|
||||||
|
const key = activeStorageKey();
|
||||||
|
contextMenuOpen = false;
|
||||||
|
actionSheetOpen = false;
|
||||||
|
if (!key) return;
|
||||||
|
// Open in a new tab — the browser surfaces its native "Save
|
||||||
|
// image as" / long-press save from there. `download` on an
|
||||||
|
// anchor would force the disk-save dialog, which is more
|
||||||
|
// direct but loses the in-tab preview some users prefer.
|
||||||
|
window.open(fileUrl(key), '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-state pill: either "Link copied" (clipboard write OK) or a
|
||||||
|
// selectable URL pill (clipboard unavailable — insecure context,
|
||||||
|
// permissions denied). Auto-clears via timer; the failure pill
|
||||||
|
// sticks around for 10s (vs. 1.6s for success) so the user has
|
||||||
|
// time to long-press → Copy on mobile without the pill vanishing
|
||||||
|
// mid-gesture. The timer is torn down on chapter navigation so
|
||||||
|
// a stale fire doesn't try to write post-unmount state.
|
||||||
|
let linkCopiedState = $state<
|
||||||
|
{ kind: 'hidden' } | { kind: 'ok' } | { kind: 'manual'; url: string }
|
||||||
|
>({ kind: 'hidden' });
|
||||||
|
let linkCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearLinkCopiedTimer() {
|
||||||
|
if (linkCopiedTimer != null) {
|
||||||
|
clearTimeout(linkCopiedTimer);
|
||||||
|
linkCopiedTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyLink() {
|
||||||
|
const n = activePageNumber();
|
||||||
|
contextMenuOpen = false;
|
||||||
|
actionSheetOpen = false;
|
||||||
|
if (n == null) return;
|
||||||
|
const url = `${window.location.origin}/manga/${manga.id}/chapter/${chapter.id}?page=${n}`;
|
||||||
|
clearLinkCopiedTimer();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
linkCopiedState = { kind: 'ok' };
|
||||||
|
linkCopiedTimer = setTimeout(() => {
|
||||||
|
linkCopiedState = { kind: 'hidden' };
|
||||||
|
linkCopiedTimer = null;
|
||||||
|
}, 1600);
|
||||||
|
} catch {
|
||||||
|
// Insecure context (HTTP on a LAN demo), missing
|
||||||
|
// permission, or some other API unavailability. Surface
|
||||||
|
// the URL in the pill so the user can copy it manually
|
||||||
|
// instead of silently swallowing — clipboard failures are
|
||||||
|
// systemic, not transient, and the silent path leaves the
|
||||||
|
// user pressing the button again with no feedback.
|
||||||
|
linkCopiedState = { kind: 'manual', url };
|
||||||
|
linkCopiedTimer = setTimeout(() => {
|
||||||
|
linkCopiedState = { kind: 'hidden' };
|
||||||
|
linkCopiedTimer = null;
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuous mode lacks TapZone, so long-press lives per-image.
|
||||||
|
// Each in-flight press is keyed by `PointerEvent.pointerId` so a
|
||||||
|
// two-finger multitouch (one finger on each of two images) tracks
|
||||||
|
// both independently — a single shared timer would let the later
|
||||||
|
// press clobber the earlier one and fire the wrong page's sheet.
|
||||||
|
const LONG_PRESS_MS = 450;
|
||||||
|
const MOVE_TOLERANCE = 8;
|
||||||
|
type Press = {
|
||||||
|
start: { x: number; y: number };
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
const presses = new Map<number, Press>();
|
||||||
|
|
||||||
|
function clearPress(pointerId: number) {
|
||||||
|
const p = presses.get(pointerId);
|
||||||
|
if (!p) return;
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
presses.delete(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllPresses() {
|
||||||
|
for (const p of presses.values()) clearTimeout(p.timer);
|
||||||
|
presses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPagePointerDown(e: PointerEvent, pageId: string) {
|
||||||
|
if (e.pointerType !== 'touch') return;
|
||||||
|
if (!session.user) return;
|
||||||
|
clearPress(e.pointerId);
|
||||||
|
const start = { x: e.clientX, y: e.clientY };
|
||||||
|
const pointerId = e.pointerId;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
presses.delete(pointerId);
|
||||||
|
openActionSheet(pageId);
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
presses.set(pointerId, { start, timer });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPagePointerMove(e: PointerEvent) {
|
||||||
|
const p = presses.get(e.pointerId);
|
||||||
|
if (!p) return;
|
||||||
|
const dx = e.clientX - p.start.x;
|
||||||
|
const dy = e.clientY - p.start.y;
|
||||||
|
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPagePointerUp(e: PointerEvent) {
|
||||||
|
clearPress(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll-pan typically fires pointercancel on the underlying touch,
|
||||||
|
// but not always — explicitly cancel every in-flight press on any
|
||||||
|
// scroll event so a long hold during a vertical pan never fires
|
||||||
|
// the action sheet for a page the user is already past. Matches
|
||||||
|
// the TapZone-level cancel-on-scroll rule.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
window.addEventListener('scroll', clearAllPresses, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', clearAllPresses, true);
|
||||||
|
clearAllPresses();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
|
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
|
||||||
// overlay opacity). Stored in localStorage only; the Preferences
|
// overlay opacity). Stored in localStorage only; the Preferences
|
||||||
// table doesn't carry this field and Phase 4 explicitly opted to
|
// table doesn't carry this field and Phase 4 explicitly opted to
|
||||||
@@ -125,6 +351,121 @@
|
|||||||
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
|
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Continuous mode previously ignored `?page=N` — the single-mode
|
||||||
|
// `initialIndex` logic above wires it into `let index = $state(...)`
|
||||||
|
// but continuous lets the user scroll naturally, so nothing
|
||||||
|
// jumped to the requested page on load. Search-result click-
|
||||||
|
// through (and any shared link) now relies on it.
|
||||||
|
//
|
||||||
|
// Two subtleties this guards against:
|
||||||
|
// 1. Lazy-load layout shift: pages with `loading="lazy"` have
|
||||||
|
// 0×0 placeholder height until they load. `scrollIntoView`
|
||||||
|
// runs on whatever the current layout says — if prior pages
|
||||||
|
// haven't loaded, the target appears far higher than its
|
||||||
|
// final position, and as the images load the target gets
|
||||||
|
// pushed past the viewport. The template eager-loads pages
|
||||||
|
// `0..=initialIndex` so they have heights, and the effect
|
||||||
|
// waits for each of them to fire `load` (or `error`) before
|
||||||
|
// scrolling.
|
||||||
|
// 2. Mode hydration race: `mode` derives from `preferences.readerMode`,
|
||||||
|
// which initialises to 'single' and is hydrated async on
|
||||||
|
// cold caches. An `onMount` would bail before the flip; an
|
||||||
|
// `$effect` re-fires when mode changes. A one-shot sentinel
|
||||||
|
// keeps us from re-scrolling on later toggles.
|
||||||
|
// 3. In-reader chapter navigation: SvelteKit reuses this
|
||||||
|
// component on same-route goto(...) (see chevron handlers
|
||||||
|
// below). The sentinel + `index` get reset by the
|
||||||
|
// chapter-change effect below so the scroll fires again for
|
||||||
|
// the new chapter's `?page=N`.
|
||||||
|
let initialScrollDone = $state(false);
|
||||||
|
|
||||||
|
// Re-seed chapter-scoped state when the chevrons (or any in-
|
||||||
|
// reader link) navigate to a sibling chapter. SvelteKit reuses
|
||||||
|
// the component on same-route nav, so without this the previous
|
||||||
|
// chapter's mutable state bleeds into the new one:
|
||||||
|
// - `index` would keep the old chapter's value (possibly out
|
||||||
|
// of range for the new chapter's page count).
|
||||||
|
// - `initialScrollDone` would stay true, so `?page=N` deep-
|
||||||
|
// link scrolls would never re-fire.
|
||||||
|
// - `progressPage` would keep the old chapter's high-water
|
||||||
|
// mark, and a pending `progressTimer` would flush it against
|
||||||
|
// the new `chapter.id` — poisoning the new chapter's stored
|
||||||
|
// read progress.
|
||||||
|
//
|
||||||
|
// `lastChapterIdSeen` is a plain `let` rather than `$state` on
|
||||||
|
// purpose — it's only read inside this effect's body and never
|
||||||
|
// drives reactivity. Making it `$state` would not change behavior
|
||||||
|
// but would suggest to a future reader that the value matters
|
||||||
|
// elsewhere.
|
||||||
|
// Two things are load-bearing here:
|
||||||
|
//
|
||||||
|
// 1. `$derived(data.chapter.id)` makes the dependency explicit.
|
||||||
|
// Reading `data.chapter.id` directly inside an effect does
|
||||||
|
// not reliably re-fire when SvelteKit hands the page a new
|
||||||
|
// `data` prop with a swapped nested chapter object —
|
||||||
|
// verified by an E2E that stayed stuck on the previous
|
||||||
|
// chapter's index across the full polling window. The
|
||||||
|
// `$derived` exists for tracking, not memoization.
|
||||||
|
//
|
||||||
|
// 2. `$effect.pre` (vs. `$effect`) lands the reset BEFORE the
|
||||||
|
// DOM mutation rather than after. The page-indicator and
|
||||||
|
// everything else that reads `index` / `pages.length` then
|
||||||
|
// render once, with both values from the new chapter. With
|
||||||
|
// a plain `$effect` the indicator rendered the previous
|
||||||
|
// chapter's `index` against the new chapter's
|
||||||
|
// `pages.length` ("Page 5 / 4") — the very symptom the
|
||||||
|
// regression test pins.
|
||||||
|
const currentChapterId = $derived(data.chapter.id);
|
||||||
|
let lastChapterIdSeen: string | null = null;
|
||||||
|
$effect.pre(() => {
|
||||||
|
const cid = currentChapterId;
|
||||||
|
if (lastChapterIdSeen !== null && lastChapterIdSeen !== cid) {
|
||||||
|
index = initialIndex;
|
||||||
|
initialScrollDone = false;
|
||||||
|
progressPage = initialProgressPage;
|
||||||
|
if (progressTimer) {
|
||||||
|
clearTimeout(progressTimer);
|
||||||
|
progressTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastChapterIdSeen = cid;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (initialScrollDone) return;
|
||||||
|
if (!browser) return;
|
||||||
|
if (mode !== 'continuous') return;
|
||||||
|
if (initialIndex === 0) {
|
||||||
|
initialScrollDone = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = continuousPageEls[initialIndex];
|
||||||
|
// continuousPageEls is populated as the {#each} mounts; the
|
||||||
|
// effect re-runs once it's bound.
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
initialScrollDone = true;
|
||||||
|
|
||||||
|
const above = continuousPageEls
|
||||||
|
.slice(0, initialIndex + 1)
|
||||||
|
.filter((el): el is HTMLImageElement => el != null);
|
||||||
|
const pending = above.filter((el) => !el.complete);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
target.scrollIntoView({ block: 'start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let remaining = pending.length;
|
||||||
|
const onResolved = () => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining === 0) target.scrollIntoView({ block: 'start' });
|
||||||
|
};
|
||||||
|
for (const el of pending) {
|
||||||
|
// `error` counts as resolved — a broken image's height is
|
||||||
|
// settled at its alt-text height, and we shouldn't hang.
|
||||||
|
el.addEventListener('load', onResolved, { once: true });
|
||||||
|
el.addEventListener('error', onResolved, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Publish the dim level as a CSS variable on <html>. (1 - brightness)
|
// Publish the dim level as a CSS variable on <html>. (1 - brightness)
|
||||||
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
|
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
|
||||||
// of the template. Persisting on every change keeps the
|
// of the template. Persisting on every change keeps the
|
||||||
@@ -367,6 +708,10 @@
|
|||||||
onMount(() => window.addEventListener('keydown', onKeydown));
|
onMount(() => window.addEventListener('keydown', onKeydown));
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
|
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
|
||||||
|
// Tear down the copy-pill timer so a fire-after-unmount
|
||||||
|
// doesn't try to write to a stale $state rune. Cheap on
|
||||||
|
// SvelteKit chapter navigation, which remounts this page.
|
||||||
|
clearLinkCopiedTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Admin force resync (current chapter) ----
|
// ---- Admin force resync (current chapter) ----
|
||||||
@@ -411,14 +756,18 @@
|
|||||||
// Writes are debounced and fire-and-forget — the reader never
|
// Writes are debounced and fire-and-forget — the reader never
|
||||||
// blocks on the network, and a failed write just means the user's
|
// blocks on the network, and a failed write just means the user's
|
||||||
// history is slightly stale (acceptable).
|
// history is slightly stale (acceptable).
|
||||||
// Route param `[n]` is part of the URL, so SvelteKit remounts
|
//
|
||||||
// this component on chapter navigation — capturing the initial
|
// `$derived` so the seed recomputes on in-reader chapter
|
||||||
// `data` value here is the desired behaviour.
|
// navigation (SvelteKit reuses the component on same-route nav).
|
||||||
// svelte-ignore state_referenced_locally
|
// The chapter-change effect above re-seeds `progressPage` from
|
||||||
const initialProgressPage =
|
// this — without it, the previous chapter's high-water mark
|
||||||
|
// carries over and gets written against the new `chapter.id`.
|
||||||
|
const initialProgressPage = $derived.by(() =>
|
||||||
data.readProgress && data.readProgress.chapter_id === chapter.id
|
data.readProgress && data.readProgress.chapter_id === chapter.id
|
||||||
? Math.max(1, data.readProgress.page)
|
? Math.max(1, data.readProgress.page)
|
||||||
: 1;
|
: 1
|
||||||
|
);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let progressPage = $state(initialProgressPage);
|
let progressPage = $state(initialProgressPage);
|
||||||
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
let progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let observer: IntersectionObserver | null = null;
|
let observer: IntersectionObserver | null = null;
|
||||||
@@ -446,26 +795,62 @@
|
|||||||
progressTimer = setTimeout(flushProgress, 1500);
|
progressTimer = setTimeout(flushProgress, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Two-part reader back-control:
|
||||||
* Reader back behavior — pop browser history instead of pushing a
|
// - The arrow is always a "go back" action: pops browser
|
||||||
* fresh entry for `/manga/{id}`. Previously this was a naked
|
// history if there's something to pop, falls back to the
|
||||||
* `<a href>` and every tap pushed, so browser-back ping-ponged
|
// detail page on a cold tab.
|
||||||
* between detail and reader instead of walking out to home.
|
// - The cover+title is smart: if the user got to the reader
|
||||||
*
|
// FROM this manga's detail page they go back (pop, no dup
|
||||||
* Just check `history.length > 1` — `document.referrer` does NOT
|
// history entry), otherwise they land on the detail page
|
||||||
* update across SvelteKit SPA navigations, so referrer-based
|
// (push). Stops the "click cover/title and end up in search
|
||||||
* gating silently fell back to the default href every time.
|
// results" regression that the naked back-everywhere code
|
||||||
* Middle-click / cmd-click / right-click are passed through so
|
// used to have.
|
||||||
* "open in new tab" still works.
|
//
|
||||||
*/
|
// `lastInternalPath` is captured by `afterNavigate({ from })`,
|
||||||
function onBackClick(e: MouseEvent) {
|
// which SvelteKit fires after every client-side navigation. It's
|
||||||
|
// the only reliable signal — `document.referrer` doesn't update
|
||||||
|
// across SPA navs (the chrome-resync at line 718 doesn't help
|
||||||
|
// either since it doesn't write referrer).
|
||||||
|
let lastInternalPath: string | null = null;
|
||||||
|
afterNavigate(({ from }) => {
|
||||||
|
lastInternalPath = from?.url?.pathname ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailPath = $derived(`/manga/${manga.id}`);
|
||||||
|
|
||||||
|
function onArrowClick(e: MouseEvent) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
// Cold tab / deep-link: nothing to pop. Push the detail
|
||||||
|
// page as the sane "back" destination.
|
||||||
|
void goto(detailPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCoverTitleClick(e: MouseEvent) {
|
||||||
|
if (!browser) return;
|
||||||
|
// Modifier / middle / non-left clicks fall through to the
|
||||||
|
// native <a> so "open in new tab", "copy link", etc. all work.
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
if (lastInternalPath === detailPath) {
|
||||||
|
// Came from THIS manga's detail page — pop so we don't
|
||||||
|
// accumulate a duplicate /manga/:id entry. preventDefault
|
||||||
|
// here stops SvelteKit's own click handler: its listener
|
||||||
|
// is on the app container in the bubble phase and bails on
|
||||||
|
// `event.defaultPrevented` (see @sveltejs/kit client.js),
|
||||||
|
// and this element-level handler runs first at the target
|
||||||
|
// phase, so the pop wins cleanly.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
// else: let the href navigate (deep-link / fresh tab path)
|
// Anywhere else (search, library, another manga's detail, cold
|
||||||
|
// tab) — do nothing here and let the <a href> navigate
|
||||||
|
// natively, which PUSHES the detail page so browser-back
|
||||||
|
// returns to the reader.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-mode: every page change moves the high-water mark.
|
// Single-mode: every page change moves the high-water mark.
|
||||||
@@ -574,27 +959,46 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
|
<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
|
||||||
<a
|
<div class="back-group">
|
||||||
href="/manga/{manga.id}"
|
<button
|
||||||
class="back"
|
type="button"
|
||||||
onclick={onBackClick}
|
class="back-arrow"
|
||||||
data-testid="back-to-manga"
|
aria-label="Back"
|
||||||
>
|
onclick={onArrowClick}
|
||||||
<ArrowLeft size={18} aria-hidden="true" />
|
data-testid="reader-back-arrow"
|
||||||
{#if manga.cover_image_path}
|
>
|
||||||
<img
|
<ArrowLeft size={18} aria-hidden="true" />
|
||||||
src={fileUrl(manga.cover_image_path)}
|
</button>
|
||||||
alt=""
|
<!--
|
||||||
class="back-cover"
|
Cover + title is a real <a href> to the detail page so
|
||||||
loading="lazy"
|
cmd/middle-click "open in new tab" and "copy link" work
|
||||||
/>
|
natively. The onclick only intercepts the one case where
|
||||||
{:else}
|
we want to POP instead of push (arrived from this manga's
|
||||||
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
own detail page) — see onCoverTitleClick. Every other
|
||||||
<BookImage size={14} aria-hidden="true" />
|
plain-left-click falls through to SvelteKit's default <a>
|
||||||
</span>
|
navigation, which pushes the detail page.
|
||||||
{/if}
|
-->
|
||||||
<span class="back-text">{manga.title}</span>
|
<a
|
||||||
</a>
|
href={detailPath}
|
||||||
|
class="back"
|
||||||
|
onclick={onCoverTitleClick}
|
||||||
|
data-testid="back-to-manga"
|
||||||
|
>
|
||||||
|
{#if manga.cover_image_path}
|
||||||
|
<img
|
||||||
|
src={fileUrl(manga.cover_image_path)}
|
||||||
|
alt=""
|
||||||
|
class="back-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="back-cover back-cover-placeholder" aria-hidden="true">
|
||||||
|
<BookImage size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="back-text">{manga.title}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="controls" role="group" aria-label="reader options">
|
<div class="controls" role="group" aria-label="reader options">
|
||||||
<label class="chapter-field desktop-control">
|
<label class="chapter-field desktop-control">
|
||||||
@@ -777,6 +1181,7 @@
|
|||||||
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
|
alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
|
||||||
class="page-image"
|
class="page-image"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
oncontextmenu={(e) => onPageContextMenu(e, pages[index].id)}
|
||||||
data-testid="reader-page"
|
data-testid="reader-page"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -811,11 +1216,22 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||||
{#each pages as p, i (p.id)}
|
{#each pages as p, i (p.id)}
|
||||||
|
<!-- Pages 0..=initialIndex (or at least 0..1) are eager
|
||||||
|
so their real heights are settled before the cold-
|
||||||
|
load scroll-to-`?page=N` effect fires. Without this,
|
||||||
|
`scrollIntoView(target)` lands while prior pages are
|
||||||
|
still 0×0 placeholders and the target gets pushed
|
||||||
|
past the viewport as they load. -->
|
||||||
<img
|
<img
|
||||||
src={fileUrl(p.storage_key)}
|
src={fileUrl(p.storage_key)}
|
||||||
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||||
class="page-image"
|
class="page-image"
|
||||||
loading={i < 2 ? 'eager' : 'lazy'}
|
loading={i <= Math.max(1, initialIndex) ? 'eager' : 'lazy'}
|
||||||
|
oncontextmenu={(e) => onPageContextMenu(e, p.id)}
|
||||||
|
onpointerdown={(e) => onPagePointerDown(e, p.id)}
|
||||||
|
onpointermove={onPagePointerMove}
|
||||||
|
onpointerup={onPagePointerUp}
|
||||||
|
onpointercancel={onPagePointerUp}
|
||||||
data-testid={`reader-page-${i + 1}`}
|
data-testid={`reader-page-${i + 1}`}
|
||||||
bind:this={continuousPageEls[i]}
|
bind:this={continuousPageEls[i]}
|
||||||
/>
|
/>
|
||||||
@@ -868,13 +1284,56 @@
|
|||||||
DOM is one zero-opacity div when brightness is at max. -->
|
DOM is one zero-opacity div when brightness is at max. -->
|
||||||
<div class="brightness-overlay" aria-hidden="true"></div>
|
<div class="brightness-overlay" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
{#if linkCopiedState.kind === 'ok'}
|
||||||
|
<div
|
||||||
|
class="link-copied"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="reader-link-copied"
|
||||||
|
>
|
||||||
|
Link copied
|
||||||
|
</div>
|
||||||
|
{:else if linkCopiedState.kind === 'manual'}
|
||||||
|
<div
|
||||||
|
class="link-copied link-copied-manual"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="reader-link-copied-manual"
|
||||||
|
>
|
||||||
|
<span class="link-copied-label">
|
||||||
|
Couldn't copy automatically — press ⌘C / long-press to copy:
|
||||||
|
</span>
|
||||||
|
<!--
|
||||||
|
`readonly` keeps the underlying page state untouchable.
|
||||||
|
`inputmode="none"` suppresses the iOS / Android soft
|
||||||
|
keyboard that would otherwise pop up under a tap, since
|
||||||
|
the user is here to copy the URL, not edit it. Focus
|
||||||
|
auto-selects so the desktop user can immediately ⌘C.
|
||||||
|
-->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
inputmode="none"
|
||||||
|
value={linkCopiedState.url}
|
||||||
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
|
data-testid="reader-link-copied-manual-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
|
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
|
||||||
scroll so left/right would steal panning. Tap left/right advances
|
scroll so left/right would steal panning. Tap left/right advances
|
||||||
within the chapter (falling through to adjacent chapters at the
|
within the chapter (falling through to adjacent chapters at the
|
||||||
boundaries via the existing prev/next helpers); tap center toggles
|
boundaries via the existing prev/next helpers); tap center toggles
|
||||||
the focus-mode chrome and restarts the idle timer. -->
|
the focus-mode chrome and restarts the idle timer. -->
|
||||||
{#if isMobileViewport && mode === 'single' && pages.length > 0}
|
{#if isMobileViewport && mode === 'single' && pages.length > 0}
|
||||||
<TapZone onPrev={prev} onNext={next} onToggle={toggleChrome} testid="reader-tap" />
|
<TapZone
|
||||||
|
onPrev={prev}
|
||||||
|
onNext={next}
|
||||||
|
onToggle={toggleChrome}
|
||||||
|
onLongPress={(_anchor) => openActionSheet(pages[index].id)}
|
||||||
|
testid="reader-tap"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
|
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
|
||||||
@@ -981,6 +1440,119 @@
|
|||||||
</div>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<!-- Page context menu (desktop right-click) and the modals/sheet it
|
||||||
|
funnels into. Rendered for authenticated users only — there's
|
||||||
|
nothing for a guest to act on. -->
|
||||||
|
{#if session.user}
|
||||||
|
<PageContextMenu
|
||||||
|
open={contextMenuOpen}
|
||||||
|
anchor={contextMenuAnchor}
|
||||||
|
onClose={() => (contextMenuOpen = false)}
|
||||||
|
onAddToCollection={handleAddToCollection}
|
||||||
|
onAddTag={handleAddTag}
|
||||||
|
onSaveImage={handleSaveImage}
|
||||||
|
onCopyLink={handleCopyLink}
|
||||||
|
collectionsCount={activePageCollectionCount}
|
||||||
|
tags={activePageTags}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mobile action sheet: same two actions, larger touch targets. -->
|
||||||
|
<Sheet
|
||||||
|
open={actionSheetOpen}
|
||||||
|
title="Page actions"
|
||||||
|
onClose={() => (actionSheetOpen = false)}
|
||||||
|
testid="page-action-sheet"
|
||||||
|
>
|
||||||
|
<ul class="action-list">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-row"
|
||||||
|
onclick={handleAddToCollection}
|
||||||
|
data-testid="page-action-add-to-collection"
|
||||||
|
>
|
||||||
|
<FolderPlus size={18} aria-hidden="true" />
|
||||||
|
<span>Add to collection</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-row"
|
||||||
|
onclick={handleAddTag}
|
||||||
|
data-testid="page-action-add-tag"
|
||||||
|
>
|
||||||
|
<Tag size={18} aria-hidden="true" />
|
||||||
|
<span>Add tag</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-row"
|
||||||
|
onclick={handleSaveImage}
|
||||||
|
data-testid="page-action-save-image"
|
||||||
|
>
|
||||||
|
<Download size={18} aria-hidden="true" />
|
||||||
|
<span>Save image</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-row"
|
||||||
|
onclick={handleCopyLink}
|
||||||
|
data-testid="page-action-copy-link"
|
||||||
|
>
|
||||||
|
<Link2 size={18} aria-hidden="true" />
|
||||||
|
<span>Copy page link</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="action-hint" data-testid="page-action-collections-line">
|
||||||
|
{#if activePageCollectionCount == null}
|
||||||
|
Loading…
|
||||||
|
{:else if activePageCollectionCount === 0}
|
||||||
|
Not in any collection
|
||||||
|
{:else}
|
||||||
|
In {activePageCollectionCount} collection{activePageCollectionCount === 1
|
||||||
|
? ''
|
||||||
|
: 's'}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="action-hint" data-testid="page-action-tags-line">
|
||||||
|
{activePageTags.length === 0
|
||||||
|
? 'No tags yet'
|
||||||
|
: `Tagged: ${activePageTags.join(', ')}`}
|
||||||
|
</p>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{#if activePageId}
|
||||||
|
<AddToCollectionModal
|
||||||
|
open={collectionsModalOpen}
|
||||||
|
target={{ kind: 'page', id: activePageId }}
|
||||||
|
onClose={() => {
|
||||||
|
collectionsModalOpen = false;
|
||||||
|
if (activePageId) void loadPageSummary(activePageId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={tagsModalOpen}
|
||||||
|
title="Tag this page"
|
||||||
|
onClose={() => (tagsModalOpen = false)}
|
||||||
|
size="md"
|
||||||
|
testid="add-tags-modal"
|
||||||
|
>
|
||||||
|
<AddTagsSheet
|
||||||
|
pageId={activePageId}
|
||||||
|
onChange={(tags) => {
|
||||||
|
activePageTags = tags;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Pinned to the viewport directly below the (also fixed) layout
|
/* Pinned to the viewport directly below the (also fixed) layout
|
||||||
header. `position: fixed` rather than `sticky` because the
|
header. `position: fixed` rather than `sticky` because the
|
||||||
@@ -1015,6 +1587,31 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-arrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 0;
|
||||||
|
padding: var(--space-1);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-arrow:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
.back {
|
.back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1178,6 +1775,17 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
/* Suppress the native mobile Safari long-press image callout
|
||||||
|
("Save Image", "Copy") and the long-press text selection so
|
||||||
|
the in-app long-press → action sheet path is the only
|
||||||
|
outcome. Cost: users lose the OS-level Save Image affordance
|
||||||
|
in the reader. Save image stays available via the in-app
|
||||||
|
action sheet.
|
||||||
|
Desktop right-click is handled separately by oncontextmenu
|
||||||
|
— holding Shift bypasses our handler for the native menu. */
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.continuous .page-image {
|
.continuous .page-image {
|
||||||
@@ -1447,6 +2055,89 @@
|
|||||||
font-weight: var(--weight-regular);
|
font-weight: var(--weight-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row:hover {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hint {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copied {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--app-header-h) + var(--space-3));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copied-manual {
|
||||||
|
/* Manual fallback needs to be interactive so the user can
|
||||||
|
select / copy the URL — override the success pill's
|
||||||
|
pointer-events: none. */
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-width: min(90vw, 32rem);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copied-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copied-manual input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
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