feat: bookmarks (CRUD + per-user listing + frontend toggle)
Backend:
- Migration 0004_bookmarks_unique.sql adds a partial unique index on
(user_id, manga_id) WHERE chapter_id IS NULL. The 0001 UNIQUE
constraint over (user_id, manga_id, chapter_id) doesn't block dupes
when chapter_id is NULL under Postgres's default NULLS DISTINCT, so a
user could otherwise bookmark the same manga twice at the manga
level. Chapter-level dupes are still caught by the 0001 constraint.
- repo::bookmark with create / list_for_user / find_owner / delete.
create catches the 23505 unique violation and surfaces it as
AppError::Conflict so handlers return a clean 409.
- POST /api/v1/bookmarks { manga_id, chapter_id?, page? } — CurrentUser
required. Pre-validates the manga exists (404 if not) and, when
chapter_id is supplied, that the chapter belongs to that manga (also
404), so FK violations can't bubble up as 500s.
- DELETE /api/v1/bookmarks/{id} — owner-only. 404 if unknown, 403 if it
exists for another user, 204 on success. Idempotent: deleting an
already-deleted bookmark is 404, not 500.
- GET /api/v1/me/bookmarks — paged envelope, sorted by created_at DESC,
scoped to the current user so the URL itself can't be used to peek at
someone else's bookmarks.
Integration coverage in tests/api_bookmarks.rs (9 cases): create+list
returns only own; duplicate manga-level bookmark → 409; unknown manga
→ 404; unauthenticated POST → 401; user A cannot delete user B's
bookmark (403); unknown delete → 404; double-delete → 404, not 500;
/me/bookmarks requires auth; paged envelope shape on empty list.
Frontend:
- lib/api/bookmarks.ts with createBookmark / deleteBookmark /
listMyBookmarks. listMyBookmarksOrEmpty wraps the 401 case so pages
can render anonymously without try/catch boilerplate.
- /manga/[id] overview: pre-loads the user's bookmark list in its load
function and renders either:
- "★ Bookmarked" / "☆ Bookmark" toggle with aria-pressed when authed;
click POSTs or DELETEs and mutates a local working copy of the
bookmark list (optimistic UI without re-fetching);
- or a "Sign in to bookmark" link for anonymous users.
- /bookmarks page lists the current user's bookmarks (chapter-level
bookmarks link into the reader, manga-level back to the overview).
Anonymous users see a sign-in prompt instead of a 401 page.
E2E in e2e/bookmarks.spec.ts (3 cases): authed toggle round-trip
(bookmark, see in /bookmarks list, unbookmark); anonymous user gets the
sign-in CTA on the overview; anonymous /bookmarks shows the sign-in
prompt. Existing reader.spec.ts updated for the new
bookmark-signin/toggle test IDs.
Lockstep version bump to 0.7.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
12
backend/migrations/0004_bookmarks_unique.sql
Normal file
12
backend/migrations/0004_bookmarks_unique.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Tighten the bookmarks uniqueness for manga-level bookmarks.
|
||||||
|
--
|
||||||
|
-- The 0001 UNIQUE constraint is (user_id, manga_id, chapter_id), but
|
||||||
|
-- PostgreSQL treats NULL as distinct under NULLS DISTINCT (the default),
|
||||||
|
-- so two manga-level bookmarks for the same (user, manga) were both
|
||||||
|
-- allowed. The partial index below blocks that exact case, while letting
|
||||||
|
-- a manga-level bookmark coexist with chapter-level bookmarks for the
|
||||||
|
-- same manga (which is the intended UX).
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX bookmarks_user_manga_no_chapter_uniq
|
||||||
|
ON bookmarks (user_id, manga_id)
|
||||||
|
WHERE chapter_id IS NULL;
|
||||||
103
backend/src/api/bookmarks.rs
Normal file
103
backend/src/api/bookmarks.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! Bookmarks — owned by a `CurrentUser`. Reads + writes both require
|
||||||
|
//! auth; the listing endpoint is scoped under `/me/bookmarks` so the
|
||||||
|
//! URL itself can't be reused to peek at another user's bookmarks.
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::api::pagination::PagedResponse;
|
||||||
|
use crate::app::AppState;
|
||||||
|
use crate::auth::extractor::CurrentUser;
|
||||||
|
use crate::domain::Bookmark;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::repo;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/bookmarks", post(create))
|
||||||
|
.route("/bookmarks/:id", delete(delete_one))
|
||||||
|
.route("/me/bookmarks", get(list_me))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct NewBookmark {
|
||||||
|
pub manga_id: Uuid,
|
||||||
|
#[serde(default)]
|
||||||
|
pub chapter_id: Option<Uuid>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub page: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListParams {
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_limit() -> i64 {
|
||||||
|
50
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Json(input): Json<NewBookmark>,
|
||||||
|
) -> AppResult<(StatusCode, Json<Bookmark>)> {
|
||||||
|
// Surface 404 on a non-existent manga / chapter rather than letting
|
||||||
|
// the foreign-key violation collapse into a generic 500.
|
||||||
|
repo::manga::get(&state.db, input.manga_id).await?;
|
||||||
|
if let Some(chapter_id) = input.chapter_id {
|
||||||
|
let exists: Option<(Uuid,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM chapters WHERE id = $1 AND manga_id = $2",
|
||||||
|
)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.bind(input.manga_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bookmark = repo::bookmark::create(
|
||||||
|
&state.db,
|
||||||
|
user.id,
|
||||||
|
input.manga_id,
|
||||||
|
input.chapter_id,
|
||||||
|
input.page,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(bookmark)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_one(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
match repo::bookmark::find_owner(&state.db, id).await? {
|
||||||
|
None => Err(AppError::NotFound),
|
||||||
|
Some(owner) if owner != user.id => Err(AppError::Forbidden),
|
||||||
|
Some(_) => {
|
||||||
|
repo::bookmark::delete(&state.db, id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Query(params): Query<ListParams>,
|
||||||
|
) -> AppResult<Json<PagedResponse<Bookmark>>> {
|
||||||
|
let limit = params.limit.clamp(1, 200);
|
||||||
|
let offset = params.offset.max(0);
|
||||||
|
let items = repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?;
|
||||||
|
Ok(Json(PagedResponse::new(items, limit, offset)))
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod bookmarks;
|
||||||
pub mod chapters;
|
pub mod chapters;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
@@ -16,4 +17,5 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.merge(chapters::routes())
|
.merge(chapters::routes())
|
||||||
.merge(files::routes())
|
.merge(files::routes())
|
||||||
.merge(auth::routes())
|
.merge(auth::routes())
|
||||||
|
.merge(bookmarks::routes())
|
||||||
}
|
}
|
||||||
|
|||||||
85
backend/src/repo/bookmark.rs
Normal file
85
backend/src/repo/bookmark.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//! Bookmark persistence.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::Bookmark;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
manga_id: Uuid,
|
||||||
|
chapter_id: Option<Uuid>,
|
||||||
|
page: Option<i32>,
|
||||||
|
) -> AppResult<Bookmark> {
|
||||||
|
let result = sqlx::query_as::<_, Bookmark>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO bookmarks (user_id, manga_id, chapter_id, page)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, manga_id, chapter_id, page, created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(manga_id)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.bind(page)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(b) => Ok(b),
|
||||||
|
Err(e) if is_unique_violation(&e) => Err(AppError::Conflict(
|
||||||
|
"bookmark already exists for this manga/chapter".into(),
|
||||||
|
)),
|
||||||
|
Err(e) => Err(AppError::Database(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_for_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> AppResult<Vec<Bookmark>> {
|
||||||
|
let rows = sqlx::query_as::<_, Bookmark>(
|
||||||
|
r#"
|
||||||
|
SELECT id, user_id, manga_id, chapter_id, page, created_at
|
||||||
|
FROM bookmarks
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult<Option<Uuid>> {
|
||||||
|
let row: Option<(Uuid,)> =
|
||||||
|
sqlx::query_as("SELECT user_id FROM bookmarks WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(uid,)| uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> {
|
||||||
|
sqlx::query("DELETE FROM bookmarks WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_unique_violation(err: &sqlx::Error) -> bool {
|
||||||
|
if let sqlx::Error::Database(db_err) = err {
|
||||||
|
db_err.code().as_deref() == Some("23505")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod api_token;
|
pub mod api_token;
|
||||||
|
pub mod bookmark;
|
||||||
pub mod chapter;
|
pub mod chapter;
|
||||||
pub mod manga;
|
pub mod manga;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
|||||||
239
backend/tests/api_bookmarks.rs
Normal file
239
backend/tests/api_bookmarks.rs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_then_list_returns_only_own(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie_a) = common::register_user(&h.app).await;
|
||||||
|
let (_, cookie_b) = common::register_user(&h.app).await;
|
||||||
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await;
|
||||||
|
|
||||||
|
// User A bookmarks the manga.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": manga_id.to_string() }),
|
||||||
|
&cookie_a,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["manga_id"], manga_id.to_string());
|
||||||
|
|
||||||
|
// User B sees nothing.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_b))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["items"], json!([]));
|
||||||
|
|
||||||
|
// User A sees their bookmark.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_a))
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_returns_409_on_duplicate_manga_level(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 make = || {
|
||||||
|
common::post_json_with_cookie(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": manga_id.to_string() }),
|
||||||
|
&cookie,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let first = h.app.clone().oneshot(make()).await.unwrap();
|
||||||
|
assert_eq!(first.status(), StatusCode::CREATED);
|
||||||
|
let second = h.app.oneshot(make()).await.unwrap();
|
||||||
|
assert_eq!(second.status(), StatusCode::CONFLICT);
|
||||||
|
let body = common::body_json(second).await;
|
||||||
|
assert_eq!(body["error"]["code"], "conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_404_on_unknown_manga(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let unknown = Uuid::nil();
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": unknown.to_string() }),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_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, "Berserk").await;
|
||||||
|
|
||||||
|
// Unauthenticated request → 401.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": manga_id.to_string() }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn user_a_cannot_delete_user_b_bookmark(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie_a) = common::register_user(&h.app).await;
|
||||||
|
let (_, cookie_b) = common::register_user(&h.app).await;
|
||||||
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await;
|
||||||
|
|
||||||
|
// User A creates a bookmark.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": manga_id.to_string() }),
|
||||||
|
&cookie_a,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
let id = body["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// User B tries to delete → 403.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::delete_with_cookie(
|
||||||
|
&format!("/api/v1/bookmarks/{id}"),
|
||||||
|
&cookie_b,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["error"]["code"], "forbidden");
|
||||||
|
|
||||||
|
// User A succeeds.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::delete_with_cookie(
|
||||||
|
&format!("/api/v1/bookmarks/{id}"),
|
||||||
|
&cookie_a,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn delete_unknown_bookmark_is_404(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::delete_with_cookie(
|
||||||
|
"/api/v1/bookmarks/00000000-0000-0000-0000-000000000000",
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn delete_already_deleted_bookmark_is_404(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 resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": manga_id.to_string() }),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let id = common::body_json(resp).await["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::delete_with_cookie(
|
||||||
|
&format!("/api/v1/bookmarks/{id}"),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// Deleting again → 404, not 500.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::delete_with_cookie(
|
||||||
|
&format!("/api/v1/bookmarks/{id}"),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_me_requires_authentication(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::get("/api/v1/me/bookmarks"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_me_returns_paged_envelope(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/bookmarks", &cookie))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert!(body["items"].is_array());
|
||||||
|
assert_eq!(body["page"]["limit"], 50);
|
||||||
|
assert_eq!(body["page"]["offset"], 0);
|
||||||
|
assert!(body["page"]["total"].is_null());
|
||||||
|
}
|
||||||
174
frontend/e2e/bookmarks.spec.ts
Normal file
174
frontend/e2e/bookmarks.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const mangaId = '22222222-2222-2222-2222-222222222222';
|
||||||
|
const userFixture = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'alice',
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
const mangaFixture = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Berserk',
|
||||||
|
author: 'Kentaro Miura',
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
const bookmarkFixture = {
|
||||||
|
id: 'b1',
|
||||||
|
user_id: 'u1',
|
||||||
|
manga_id: mangaId,
|
||||||
|
chapter_id: null,
|
||||||
|
page: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setupAuthenticatedBookmarkFlow(page: Page) {
|
||||||
|
let bookmarks: typeof bookmarkFixture[] = [];
|
||||||
|
|
||||||
|
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/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: [], page: { limit: 50, offset: 0, total: null } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: null } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: bookmarks,
|
||||||
|
page: { limit: 50, offset: 0, total: null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/bookmarks', (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
bookmarks = [bookmarkFixture, ...bookmarks];
|
||||||
|
route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(bookmarkFixture)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
route.fallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/bookmarks/b1', (route) => {
|
||||||
|
if (route.request().method() === 'DELETE') {
|
||||||
|
bookmarks = bookmarks.filter((b) => b.id !== 'b1');
|
||||||
|
route.fulfill({ status: 204 });
|
||||||
|
} else {
|
||||||
|
route.fallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('authed user toggles a manga bookmark and sees it in /bookmarks', async ({ page }) => {
|
||||||
|
await setupAuthenticatedBookmarkFlow(page);
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
const toggle = page.getByTestId('bookmark-toggle');
|
||||||
|
await expect(toggle).toHaveText('☆ Bookmark');
|
||||||
|
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveText('★ Bookmarked');
|
||||||
|
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// The /bookmarks list reflects it.
|
||||||
|
await page.goto('/bookmarks');
|
||||||
|
await expect(page.getByTestId('bookmark-list')).toContainText('Manga bookmark');
|
||||||
|
|
||||||
|
// Toggle off from the manga page.
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
const toggle2 = page.getByTestId('bookmark-toggle');
|
||||||
|
await expect(toggle2).toHaveText('★ Bookmarked');
|
||||||
|
await toggle2.click();
|
||||||
|
await expect(toggle2).toHaveText('☆ Bookmark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anonymous user sees a sign-in CTA instead of a toggle', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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: [], page: { limit: 50, offset: 0, total: null } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: null } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
await expect(page.getByTestId('bookmark-signin')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('bookmark-toggle')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/bookmarks page prompts anonymous users to sign in', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/bookmarks');
|
||||||
|
await expect(page.getByTestId('bookmarks-signin')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -52,6 +52,13 @@ async function mockReaderApis(page: Page) {
|
|||||||
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -111,7 +118,7 @@ test('manga overview shows title, cover, and a chapter list', async ({ page }) =
|
|||||||
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
|
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
|
||||||
await expect(page.getByTestId('manga-cover')).toBeVisible();
|
await expect(page.getByTestId('manga-cover')).toBeVisible();
|
||||||
await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1');
|
await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1');
|
||||||
await expect(page.getByTestId('bookmark-placeholder')).toBeDisabled();
|
await expect(page.getByTestId('bookmark-signin')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reader paginates with arrow keys and j/k, and preloads the next page', async ({ page }) => {
|
test('reader paginates with arrow keys and j/k, and preloads the next page', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
102
frontend/src/lib/api/bookmarks.test.ts
Normal file
102
frontend/src/lib/api/bookmarks.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type MockInstance
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
createBookmark,
|
||||||
|
deleteBookmark,
|
||||||
|
listMyBookmarks,
|
||||||
|
listMyBookmarksOrEmpty
|
||||||
|
} from './bookmarks';
|
||||||
|
|
||||||
|
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 envelope(status: number, code: string, message: string): Response {
|
||||||
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarkFixture = {
|
||||||
|
id: 'b1',
|
||||||
|
user_id: 'u1',
|
||||||
|
manga_id: 'm1',
|
||||||
|
chapter_id: null,
|
||||||
|
page: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('bookmarks api client', () => {
|
||||||
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createBookmark POSTs JSON to /v1/bookmarks', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(bookmarkFixture, 201));
|
||||||
|
const b = await createBookmark({ manga_id: 'm1' });
|
||||||
|
expect(b).toEqual(bookmarkFixture);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/bookmarks$/);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ manga_id: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createBookmark surfaces 409 conflict', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'already bookmarked'));
|
||||||
|
await expect(createBookmark({ manga_id: 'm1' })).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
code: 'conflict'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteBookmark DELETEs /v1/bookmarks/{id} and handles 204', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(noContent());
|
||||||
|
await expect(deleteBookmark('b1')).resolves.toBeUndefined();
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/bookmarks\/b1$/);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listMyBookmarks hits /v1/me/bookmarks and returns paged envelope', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
ok({ items: [bookmarkFixture], page: { limit: 50, offset: 0, total: null } })
|
||||||
|
);
|
||||||
|
const result = await listMyBookmarks();
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/me\/bookmarks$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listMyBookmarksOrEmpty returns empty page on 401', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
|
||||||
|
const result = await listMyBookmarksOrEmpty();
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listMyBookmarksOrEmpty re-throws non-401', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'oops'));
|
||||||
|
await expect(listMyBookmarksOrEmpty()).rejects.toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/src/lib/api/bookmarks.ts
Normal file
60
frontend/src/lib/api/bookmarks.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { ApiError, request, type Page } from './client';
|
||||||
|
|
||||||
|
export type Bookmark = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
manga_id: string;
|
||||||
|
chapter_id: string | null;
|
||||||
|
page: number | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BookmarksPage = {
|
||||||
|
items: Bookmark[];
|
||||||
|
page: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewBookmark = {
|
||||||
|
manga_id: string;
|
||||||
|
chapter_id?: string | null;
|
||||||
|
page?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createBookmark(input: NewBookmark): Promise<Bookmark> {
|
||||||
|
return request<Bookmark>('/v1/bookmarks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBookmark(id: string): Promise<void> {
|
||||||
|
await request<void>(`/v1/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListMyOptions = { limit?: number; offset?: number };
|
||||||
|
|
||||||
|
export async function listMyBookmarks(
|
||||||
|
opts: ListMyOptions = {}
|
||||||
|
): Promise<BookmarksPage> {
|
||||||
|
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<BookmarksPage>(`/v1/me/bookmarks${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's bookmarks, or an empty page if they're not
|
||||||
|
* authenticated. Re-throws any non-401 error.
|
||||||
|
*/
|
||||||
|
export async function listMyBookmarksOrEmpty(): Promise<BookmarksPage> {
|
||||||
|
try {
|
||||||
|
return await listMyBookmarks();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
return { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/src/routes/bookmarks/+page.svelte
Normal file
50
frontend/src/routes/bookmarks/+page.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
const authenticated = $derived(data.authenticated);
|
||||||
|
const bookmarks = $derived(data.bookmarks);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Bookmarks — Mangalord</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Bookmarks</h1>
|
||||||
|
|
||||||
|
{#if !authenticated}
|
||||||
|
<p data-testid="bookmarks-signin">
|
||||||
|
<a href="/login">Sign in</a> to see your bookmarks.
|
||||||
|
</p>
|
||||||
|
{:else if bookmarks.length === 0}
|
||||||
|
<p data-testid="bookmarks-empty">No bookmarks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="bookmark-list" data-testid="bookmark-list">
|
||||||
|
{#each bookmarks as b (b.id)}
|
||||||
|
<li>
|
||||||
|
{#if b.chapter_id}
|
||||||
|
<a href="/manga/{b.manga_id}/chapter/{b.chapter_id}">
|
||||||
|
Chapter bookmark
|
||||||
|
{#if b.page}— page {b.page}{/if}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href="/manga/{b.manga_id}">Manga bookmark</a>
|
||||||
|
{/if}
|
||||||
|
<span class="created">{new Date(b.created_at).toLocaleDateString()}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bookmark-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.bookmark-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.created {
|
||||||
|
color: #888;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
frontend/src/routes/bookmarks/+page.ts
Normal file
17
frontend/src/routes/bookmarks/+page.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { listMyBookmarks } from '$lib/api/bookmarks';
|
||||||
|
import { ApiError } from '$lib/api/client';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
|
try {
|
||||||
|
const page = await listMyBookmarks();
|
||||||
|
return { bookmarks: page.items, authenticated: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
|
return { bookmarks: [], authenticated: false };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,9 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fileUrl } from '$lib/api/client';
|
import { fileUrl } from '$lib/api/client';
|
||||||
|
import { createBookmark, deleteBookmark, type Bookmark } from '$lib/api/bookmarks';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const manga = $derived(data.manga);
|
const manga = $derived(data.manga);
|
||||||
const chapters = $derived(data.chapters);
|
const chapters = $derived(data.chapters);
|
||||||
|
|
||||||
|
// Local working copy of the bookmark list — mutated optimistically
|
||||||
|
// when the user toggles, instead of re-fetching from the server.
|
||||||
|
// The route re-mounts on /manga/{id} → /manga/{other} navigation,
|
||||||
|
// so capturing the initial value here is the desired behaviour.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let bookmarks = $state<Bookmark[]>([...data.bookmarks]);
|
||||||
|
|
||||||
|
const mangaBookmark = $derived(
|
||||||
|
bookmarks.find((b) => b.manga_id === manga.id && b.chapter_id === null) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
async function toggleBookmark() {
|
||||||
|
if (!session.user) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
if (mangaBookmark) {
|
||||||
|
const id = mangaBookmark.id;
|
||||||
|
await deleteBookmark(id);
|
||||||
|
bookmarks = bookmarks.filter((b) => b.id !== id);
|
||||||
|
} else {
|
||||||
|
const b = await createBookmark({ manga_id: manga.id });
|
||||||
|
bookmarks = [b, ...bookmarks];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -29,16 +61,24 @@
|
|||||||
{#if manga.description}
|
{#if manga.description}
|
||||||
<p class="description" data-testid="manga-description">{manga.description}</p>
|
<p class="description" data-testid="manga-description">{manga.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if session.user}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="bookmark"
|
class="bookmark"
|
||||||
disabled
|
class:active={mangaBookmark}
|
||||||
aria-disabled="true"
|
onclick={toggleBookmark}
|
||||||
title="Bookmarking lands in feat/bookmarks"
|
disabled={busy}
|
||||||
data-testid="bookmark-placeholder"
|
aria-pressed={mangaBookmark ? 'true' : 'false'}
|
||||||
|
data-testid="bookmark-toggle"
|
||||||
>
|
>
|
||||||
☆ Bookmark
|
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a class="bookmark" href="/login" data-testid="bookmark-signin">
|
||||||
|
Sign in to bookmark
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -89,7 +129,23 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.bookmark {
|
.bookmark {
|
||||||
|
display: inline-block;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bookmark:focus-visible {
|
||||||
|
outline: 2px solid #06f;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.bookmark.active {
|
||||||
|
background: #ffeebb;
|
||||||
|
border-color: #d6a800;
|
||||||
}
|
}
|
||||||
.chapter-list {
|
.chapter-list {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { getManga } from '$lib/api/mangas';
|
import { getManga } from '$lib/api/mangas';
|
||||||
import { listChapters } from '$lib/api/chapters';
|
import { listChapters } from '$lib/api/chapters';
|
||||||
|
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const [manga, chapters] = await Promise.all([
|
const [manga, chapters, bookmarks] = await Promise.all([
|
||||||
getManga(params.id),
|
getManga(params.id),
|
||||||
listChapters(params.id)
|
listChapters(params.id),
|
||||||
|
listMyBookmarksOrEmpty()
|
||||||
]);
|
]);
|
||||||
return { manga, chapters: chapters.items };
|
return { manga, chapters: chapters.items, bookmarks: bookmarks.items };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user