Compare commits

...

5 Commits

Author SHA1 Message Date
MechaCat02
ef8d226ba6 fix(detail): resync reaction toggle across manga navigation
All checks were successful
deploy / test-backend (push) Successful in 36m1s
deploy / test-frontend (push) Successful in 10m41s
deploy / build-and-push (push) Successful in 11m41s
deploy / deploy (push) Successful in 13s
The detail page component is reused across /manga/A -> /manga/B
navigations (clicking a similar or recommendation card), so
ReactionButtons' locally-held `current` state kept the previous manga's
reaction until interacted with — a visibly wrong thumb on the new page.
Re-seed `current` from the loader-supplied prop via an effect keyed on
mangaId. Also fetch the homepage's two independent /me/* shelves
concurrently (Promise.allSettled) instead of in series.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:17:18 +02:00
MechaCat02
19db66a845 feat(home): add a "Recommended for you" shelf
Surface the content-based /me/recommendations feed as a horizontal shelf on
the homepage, below the Continue-reading shelf. Fetched after the catalogue
(same non-blocking pattern), shown only when signed in and the feed is
non-empty; reuses MangaCard in a fixed-width scroller. Component unit test +
e2e (signed-in shows cards, anonymous hides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:46:33 +02:00
MechaCat02
d6a109df2d feat(recommendations): content-based "Recommended for you" endpoint
Add GET /me/recommendations: rank mangas by weighted tag overlap with the
user's taste — explicit like +1.0, bookmark +0.5, dislike −1.0 (reaction
overrides bookmark). Per-tag affinities are summed, candidates scored by
their tags' affinity normalized by tag count (anti-tag-stuffing, à la
list_similar), with already-reacted/bookmarked/read mangas excluded and
net-negative candidates dropped (dislike down-ranks, not browse-hides).
Reuses manga_cols/cards_from_rows. Integration tests cover like-driven recs,
dislike down-rank, bookmark half-weight ordering, seen-exclusion, empty
cold-start, and auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:42:05 +02:00
MechaCat02
bb833c7e71 feat(detail): like/dislike buttons on the manga page
Add a tri-state like/dislike toggle beside the bookmark action (signed-in
only): click a reaction to set it, the other to switch, or the active one to
clear — optimistic with rollback, mirroring the bookmark toggle. Seeds from
the new GET /me/reactions/:id in the detail loader (non-critical: any failure
degrades to an unset toggle). Unit tests cover the state machine; e2e drives
set → switch → clear against the real endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:20:24 +02:00
MechaCat02
258a536254 feat(reactions): per-user like/dislike on mangas
Add a private per-user reaction resource: a manga_reactions table (one row
per user+manga, like/dislike, cascades) and endpoints PUT/DELETE
/mangas/:id/reaction plus GET /me/reactions/:manga_id, wired on the existing
per-user seam (mirrors bookmarks/read_progress). Unknown manga → 404 via the
FK-violation mapping; an invalid reaction value → 422. Reactions are never
exposed publicly — this is a taste signal for the upcoming content-based
recommendations. Integration tests cover set/toggle/clear/read, per-user
isolation, and the 404/422/401 paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:54:11 +02:00
26 changed files with 1136 additions and 15 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.105.0"
version = "0.109.1"
dependencies = [
"anyhow",
"argon2",

View File

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

View File

@@ -0,0 +1,15 @@
-- Per-user like/dislike reactions on mangas — a private taste signal that
-- powers content-based recommendations. One row per (user, manga); the
-- `reaction` column toggles between 'like' and 'dislike', and clearing a
-- reaction deletes the row. Reactions are never exposed publicly (no counts).
CREATE TABLE manga_reactions (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
reaction text NOT NULL CHECK (reaction IN ('like', 'dislike')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, manga_id)
);
-- Recommendations aggregate a user's reacted mangas by tag; the PK covers
-- per-user lookups, this covers the reverse (all reactions on a manga).
CREATE INDEX manga_reactions_manga_idx ON manga_reactions (manga_id);

View File

@@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one).patch(update))
.route("/mangas/:id/similar", get(list_similar))
.route("/me/recommendations", get(list_recommendations))
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
.route("/mangas/:id/tags", post(attach_tag))
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
@@ -188,6 +189,28 @@ async fn list_similar(
Ok(Json(json!({ "items": items })))
}
const RECOMMENDATIONS_LIMIT: i64 = 12;
#[derive(Debug, Deserialize)]
pub struct RecommendationParams {
#[serde(default)]
pub limit: Option<i64>,
}
/// `GET /api/v1/me/recommendations` — content-based "Recommended for you",
/// ranked by tag overlap with the signed-in user's likes/bookmarks (minus
/// dislikes). Returns `{ "items": [...] }` (a fixed top-N, like `/similar`);
/// empty when the user has no taste signals yet.
async fn list_recommendations(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<RecommendationParams>,
) -> AppResult<Json<serde_json::Value>> {
let limit = params.limit.unwrap_or(RECOMMENDATIONS_LIMIT).clamp(1, 50);
let items = repo::manga::list_recommendations(&state.db, user.id, limit).await?;
Ok(Json(json!({ "items": items })))
}
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
///
/// - `metadata` (required): JSON body matching `NewManga` — title, optional

View File

@@ -11,6 +11,7 @@ pub mod history;
pub mod mangas;
pub mod page_tags;
pub mod pagination;
pub mod reactions;
pub mod tags;
use axum::Router;
@@ -31,5 +32,6 @@ pub fn routes() -> Router<AppState> {
.merge(collections::routes())
.merge(page_tags::routes())
.merge(history::routes())
.merge(reactions::routes())
.merge(admin::routes())
}

View File

@@ -0,0 +1,69 @@
//! Manga reactions (like/dislike) — a private, per-user taste signal.
//! Writes require auth; the read is scoped under `/me/` so the URL can't be
//! used to peek at another user's reactions.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::reaction::{MangaReaction, Reaction};
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/mangas/:id/reaction",
put(set_reaction).delete(clear_reaction),
)
.route("/me/reactions/:manga_id", get(get_reaction))
}
#[derive(Debug, Deserialize)]
pub struct SetReactionBody {
pub reaction: String,
}
async fn set_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
Json(body): Json<SetReactionBody>,
) -> AppResult<Json<MangaReaction>> {
// Validate against the closed vocabulary here so a bad value is a clean
// 422 rather than relying on the DB CHECK to surface as a 500.
let reaction = Reaction::parse(&body.reaction).ok_or_else(|| AppError::ValidationFailed {
message: "reaction must be 'like' or 'dislike'".into(),
details: json!({ "reaction": "must be 'like' or 'dislike'" }),
})?;
// Unknown manga → 404 via the FK-violation mapping in repo::reaction.
repo::reaction::upsert(&state.db, user.id, manga_id, reaction).await?;
Ok(Json(MangaReaction {
manga_id,
reaction: Some(reaction),
}))
}
async fn clear_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<StatusCode> {
repo::reaction::clear(&state.db, user.id, manga_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaReaction>> {
let reaction = repo::reaction::get(&state.db, user.id, manga_id).await?;
Ok(Json(MangaReaction { manga_id, reaction }))
}

View File

@@ -11,6 +11,7 @@ pub mod page;
pub mod page_analysis;
pub mod page_tag;
pub mod patch;
pub mod reaction;
pub mod read_progress;
pub mod session;
pub mod storage_stats;

View File

@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user's private taste signal on a manga. Stored as text in
/// `manga_reactions.reaction` (CHECK-constrained), so we map to/from a
/// `&str` at the repo layer rather than deriving a Postgres enum type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Reaction {
Like,
Dislike,
}
impl Reaction {
pub fn as_str(self) -> &'static str {
match self {
Reaction::Like => "like",
Reaction::Dislike => "dislike",
}
}
/// Parse a stored/inbound value. Returns `None` for anything outside the
/// closed vocabulary (the DB CHECK guarantees stored rows are valid; this
/// also guards the inbound API body).
pub fn parse(s: &str) -> Option<Reaction> {
match s {
"like" => Some(Reaction::Like),
"dislike" => Some(Reaction::Dislike),
_ => None,
}
}
}
/// Response shape for `GET /me/reactions/:manga_id` — the current user's
/// reaction on one manga, or `null` when they haven't reacted.
#[derive(Debug, Clone, Serialize)]
pub struct MangaReaction {
pub manga_id: Uuid,
pub reaction: Option<Reaction>,
}

View File

@@ -307,6 +307,73 @@ pub async fn list_similar(
cards_from_rows(pool, rows).await
}
/// Content-based "Recommended for you": rank mangas by weighted tag overlap
/// with the user's taste. Signals: explicit like = +1.0, bookmark = +0.5,
/// dislike = -1.0 (a reaction overrides a bookmark on the same manga). Per
/// tag we sum those weights into an affinity, then score each candidate by
/// the sum of its tags' affinities, normalized by the candidate's tag count
/// (same anti-tag-stuffing rationale as `list_similar`). Candidates the user
/// already reacted to, bookmarked, or read are excluded; net-negative
/// candidates (dominated by disliked-tag affinity) are dropped, so a dislike
/// down-ranks rather than the manga being hidden from normal browse. No
/// signals → empty. Reuses `cards_from_rows` for author/genre hydration.
pub async fn list_recommendations(
pool: &PgPool,
user_id: Uuid,
limit: i64,
) -> AppResult<Vec<MangaCard>> {
let sql = format!(
r#"
WITH signals AS (
SELECT s.manga_id,
CASE WHEN r.reaction = 'dislike' THEN -1.0
WHEN r.reaction = 'like' THEN 1.0
ELSE 0.5 END AS weight
FROM (
SELECT manga_id FROM manga_reactions WHERE user_id = $1
UNION
SELECT manga_id FROM bookmarks WHERE user_id = $1
) s
LEFT JOIN manga_reactions r
ON r.user_id = $1 AND r.manga_id = s.manga_id
),
tag_affinity AS (
SELECT mt.tag_id, SUM(sig.weight) AS affinity
FROM signals sig
JOIN manga_tags mt ON mt.manga_id = sig.manga_id
GROUP BY mt.tag_id
)
SELECT {cols}
FROM manga_tags cand
JOIN tag_affinity ta ON ta.tag_id = cand.tag_id
JOIN mangas m ON m.id = cand.manga_id
WHERE cand.manga_id NOT IN (SELECT manga_id FROM signals)
AND cand.manga_id NOT IN (
SELECT manga_id FROM read_progress WHERE user_id = $1
)
GROUP BY m.id
HAVING SUM(ta.affinity) > 0
ORDER BY
SUM(ta.affinity)
/ (SELECT count(*) FROM manga_tags WHERE manga_id = m.id) DESC,
SUM(ta.affinity) DESC,
m.updated_at DESC,
lower(m.title) ASC,
m.id
LIMIT $2
"#,
cols = manga_cols("m"),
);
let rows = sqlx::query_as::<_, Manga>(&sql)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
cards_from_rows(pool, rows).await
}
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
/// authors and genres in two batched round-trips. The input order is
/// preserved (callers rely on this to keep list/ranking order), so we

View File

@@ -13,6 +13,7 @@ pub mod manga;
pub mod page;
pub mod page_analysis;
pub mod page_tag;
pub mod reaction;
pub mod read_progress;
pub mod session;
pub mod storage_stats;

View File

@@ -0,0 +1,66 @@
//! Per-user manga reaction (like/dislike) persistence.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::reaction::Reaction;
use crate::error::{AppError, AppResult};
/// Insert-or-overwrite the user's reaction on this manga (like ↔ dislike).
/// A foreign-key violation (unknown manga) maps to `NotFound` so the API
/// returns 404 rather than 500 — mirrors `read_progress::upsert`.
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
reaction: Reaction,
) -> AppResult<()> {
sqlx::query(
r#"
INSERT INTO manga_reactions (user_id, manga_id, reaction, created_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, manga_id) DO UPDATE
SET reaction = EXCLUDED.reaction,
created_at = now()
"#,
)
.bind(user_id)
.bind(manga_id)
.bind(reaction.as_str())
.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(())
}
/// Remove the user's reaction on this manga. Idempotent — clearing a
/// non-existent reaction is a no-op.
pub async fn clear(pool: &PgPool, user_id: Uuid, manga_id: Uuid) -> AppResult<()> {
sqlx::query("DELETE FROM manga_reactions WHERE user_id = $1 AND manga_id = $2")
.bind(user_id)
.bind(manga_id)
.execute(pool)
.await?;
Ok(())
}
/// The user's current reaction on this manga, or `None` if unset.
pub async fn get(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
) -> AppResult<Option<Reaction>> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT reaction FROM manga_reactions WHERE user_id = $1 AND manga_id = $2",
)
.bind(user_id)
.bind(manga_id)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|(s,)| Reaction::parse(&s)))
}

View File

@@ -0,0 +1,133 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
async fn put_reaction(
app: &axum::Router,
cookie: &str,
manga_id: Uuid,
reaction: &str,
) -> StatusCode {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/reaction"),
json!({ "reaction": reaction }),
cookie,
))
.await
.unwrap();
resp.status()
}
async fn get_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid) -> serde_json::Value {
let resp = app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/reactions/{manga_id}"),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
common::body_json(resp).await
}
#[sqlx::test(migrations = "./migrations")]
async fn put_creates_and_reads_back(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;
assert_eq!(put_reaction(&h.app, &cookie, manga_id, "like").await, StatusCode::OK);
let body = get_reaction(&h.app, &cookie, manga_id).await;
assert_eq!(body["reaction"], "like");
assert_eq!(body["manga_id"], manga_id.to_string());
}
#[sqlx::test(migrations = "./migrations")]
async fn put_toggles_like_to_dislike(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 _ = put_reaction(&h.app, &cookie, manga_id, "like").await;
assert_eq!(put_reaction(&h.app, &cookie, manga_id, "dislike").await, StatusCode::OK);
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], "dislike");
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_clears_the_reaction(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 _ = put_reaction(&h.app, &cookie, manga_id, "like").await;
let resp = h
.app
.clone()
.oneshot(common::delete_with_cookie(
&format!("/api/v1/mangas/{manga_id}/reaction"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_unset_returns_null(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;
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn reactions_are_per_user(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, "Berserk").await;
let _ = put_reaction(&h.app, &a, manga_id, "like").await;
// B sees no reaction of their own.
assert_eq!(get_reaction(&h.app, &b, manga_id).await["reaction"], serde_json::Value::Null);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_unknown_manga_is_404(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
assert_eq!(
put_reaction(&h.app, &cookie, Uuid::new_v4(), "like").await,
StatusCode::NOT_FOUND
);
}
#[sqlx::test(migrations = "./migrations")]
async fn put_invalid_reaction_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, "Berserk").await;
assert_eq!(
put_reaction(&h.app, &cookie, manga_id, "meh").await,
StatusCode::UNPROCESSABLE_ENTITY
);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.clone()
.oneshot(common::get(&format!("/api/v1/me/reactions/{}", Uuid::new_v4())))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

View File

@@ -0,0 +1,182 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: Uuid, name: &str) {
let resp = app
.clone()
.oneshot(common::post_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/tags"),
json!({ "name": name }),
cookie,
))
.await
.unwrap();
assert!(resp.status().is_success(), "attach_tag: {}", resp.status());
}
async fn set_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid, reaction: &str) {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
&format!("/api/v1/mangas/{manga_id}/reaction"),
json!({ "reaction": reaction }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
async fn bookmark(app: &axum::Router, cookie: &str, manga_id: Uuid) {
let resp = app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
async fn mark_read(app: &axum::Router, cookie: &str, manga_id: Uuid) {
let resp = app
.clone()
.oneshot(common::put_json_with_cookie(
"/api/v1/me/read-progress",
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
/// Recommended manga titles, in ranked order.
async fn recommend(app: &axum::Router, cookie: &str) -> Vec<String> {
let resp = app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/recommendations", cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
body["items"]
.as_array()
.unwrap()
.iter()
.map(|m| m["title"].as_str().unwrap().to_string())
.collect()
}
#[sqlx::test(migrations = "./migrations")]
async fn likes_drive_recommendations(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let similar = common::seed_manga_via_api(&h.app, &cookie, "Similar").await;
let unrelated = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
attach_tag(&h.app, &cookie, liked, "action").await;
attach_tag(&h.app, &cookie, similar, "action").await;
attach_tag(&h.app, &cookie, unrelated, "sports").await;
set_reaction(&h.app, &cookie, liked, "like").await;
let recs = recommend(&h.app, &cookie).await;
assert!(recs.contains(&"Similar".to_string()), "recs: {recs:?}");
assert!(!recs.contains(&"Unrelated".to_string()), "recs: {recs:?}");
// The liked manga itself is not recommended back.
assert!(!recs.contains(&"Liked".to_string()), "recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn dislike_downranks_shared_tags(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let disliked = common::seed_manga_via_api(&h.app, &cookie, "Disliked").await;
let good = common::seed_manga_via_api(&h.app, &cookie, "Good").await;
let bad = common::seed_manga_via_api(&h.app, &cookie, "Bad").await;
attach_tag(&h.app, &cookie, liked, "action").await;
attach_tag(&h.app, &cookie, good, "action").await; // shares the liked tag
attach_tag(&h.app, &cookie, disliked, "gore").await;
attach_tag(&h.app, &cookie, bad, "gore").await; // shares the disliked tag
set_reaction(&h.app, &cookie, liked, "like").await;
set_reaction(&h.app, &cookie, disliked, "dislike").await;
let recs = recommend(&h.app, &cookie).await;
assert!(recs.contains(&"Good".to_string()), "recs: {recs:?}");
// Net-negative (disliked-tag) candidate is dropped.
assert!(!recs.contains(&"Bad".to_string()), "recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn bookmark_counts_as_half_a_like(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let booked = common::seed_manga_via_api(&h.app, &cookie, "Booked").await;
let from_like = common::seed_manga_via_api(&h.app, &cookie, "FromLike").await;
let from_bookmark = common::seed_manga_via_api(&h.app, &cookie, "FromBookmark").await;
attach_tag(&h.app, &cookie, liked, "tliked").await;
attach_tag(&h.app, &cookie, from_like, "tliked").await; // affinity 1.0
attach_tag(&h.app, &cookie, booked, "tbooked").await;
attach_tag(&h.app, &cookie, from_bookmark, "tbooked").await; // affinity 0.5
set_reaction(&h.app, &cookie, liked, "like").await;
bookmark(&h.app, &cookie, booked).await;
let recs = recommend(&h.app, &cookie).await;
// Both recommended, but the like-derived one outranks the bookmark-derived.
let i_like = recs.iter().position(|t| t == "FromLike");
let i_book = recs.iter().position(|t| t == "FromBookmark");
assert!(i_like.is_some() && i_book.is_some(), "recs: {recs:?}");
assert!(i_like < i_book, "like should outrank bookmark; recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn excludes_already_seen(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
let fresh = common::seed_manga_via_api(&h.app, &cookie, "Fresh").await;
let already_read = common::seed_manga_via_api(&h.app, &cookie, "AlreadyRead").await;
for m in [liked, fresh, already_read] {
attach_tag(&h.app, &cookie, m, "action").await;
}
set_reaction(&h.app, &cookie, liked, "like").await;
mark_read(&h.app, &cookie, already_read).await;
let recs = recommend(&h.app, &cookie).await;
assert!(recs.contains(&"Fresh".to_string()), "recs: {recs:?}");
assert!(!recs.contains(&"AlreadyRead".to_string()), "recs: {recs:?}");
}
#[sqlx::test(migrations = "./migrations")]
async fn empty_without_signals(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let m = common::seed_manga_via_api(&h.app, &cookie, "Whatever").await;
attach_tag(&h.app, &cookie, m, "action").await;
assert!(recommend(&h.app, &cookie).await.is_empty());
}
#[sqlx::test(migrations = "./migrations")]
async fn requires_authentication(pool: PgPool) {
let h = common::harness(pool);
let resp = h
.app
.clone()
.oneshot(common::get("/api/v1/me/recommendations"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

View File

@@ -0,0 +1,85 @@
import { test, expect, type Page } from './fixtures';
// Detail-page like/dislike toggle: reflects initial state, sets/switches via
// PUT, and clears the active reaction via DELETE.
const mangaId = 'a1111111-1111-1111-1111-111111111111';
type Captured = { method: string; reaction?: string };
async function mockDetail(page: Page, captured: Captured[]) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } }) })
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no' } }) })
);
// Reaction: initially unset; capture writes.
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ manga_id: mangaId, reaction: null }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/reaction`, (route) => {
const method = route.request().method();
if (method === 'PUT') {
const reaction = route.request().postDataJSON()?.reaction as string;
captured.push({ method, reaction });
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ manga_id: mangaId, reaction }) });
}
captured.push({ method });
return route.fulfill({ status: 204, body: '' });
});
// getManga last so it wins over the chapters glob.
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('like, switch to dislike, then clear', async ({ page }) => {
const captured: Captured[] = [];
await mockDetail(page, captured);
await page.goto(`/manga/${mangaId}`);
const like = page.getByTestId('reaction-like');
const dislike = page.getByTestId('reaction-dislike');
await expect(like).toHaveAttribute('aria-pressed', 'false');
await expect(dislike).toHaveAttribute('aria-pressed', 'false');
// Like.
await like.click();
await expect(like).toHaveAttribute('aria-pressed', 'true');
await expect.poll(() => captured.at(-1)).toEqual({ method: 'PUT', reaction: 'like' });
// Switch to dislike.
await dislike.click();
await expect(dislike).toHaveAttribute('aria-pressed', 'true');
await expect(like).toHaveAttribute('aria-pressed', 'false');
await expect.poll(() => captured.at(-1)).toEqual({ method: 'PUT', reaction: 'dislike' });
// Click the active dislike again → clear.
await dislike.click();
await expect(dislike).toHaveAttribute('aria-pressed', 'false');
await expect.poll(() => captured.at(-1)?.method).toBe('DELETE');
});

View File

@@ -0,0 +1,59 @@
import { test, expect, type Page } from './fixtures';
// Homepage "Recommended for you" shelf: visible with cards when signed in and
// the feed is non-empty; absent for anonymous visitors (401 → empty).
const emptyMangas = { items: [], page: { limit: 50, offset: 0, total: 0 } };
function manga(id: string, title: string) {
return {
id, title, status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: []
};
}
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) })
);
await page.route('**/api/v1/genres*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
await page.route('**/api/v1/mangas*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptyMangas) })
);
// Keep the Continue-reading shelf out of the way.
await page.route('**/api/v1/me/read-progress*', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
}
test('shows the recommendations shelf with cards when signed in', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 'u1', username: 'reader', is_admin: false } }) })
);
await page.route('**/api/v1/me/recommendations*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [manga('m1', 'Berserk'), manga('m2', 'Vinland')] }) })
);
await page.goto('/');
await expect(page.getByTestId('recommendations-shelf')).toBeVisible();
await expect(page.getByTestId('rec-card-m1')).toBeVisible();
await expect(page.getByTestId('rec-card-m2')).toBeVisible();
});
test('hides the recommendations shelf for anonymous visitors', async ({ page }) => {
await mockCommon(page);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.route('**/api/v1/me/recommendations*', (r) =>
r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) })
);
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Mangas' })).toBeVisible();
await expect(page.getByTestId('recommendations-shelf')).toHaveCount(0);
});

View File

@@ -1,12 +1,12 @@
{
"name": "mangalord-frontend",
"version": "0.105.0",
"version": "0.109.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mangalord-frontend",
"version": "0.105.0",
"version": "0.109.1",
"devDependencies": {
"@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0",

View File

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

View File

@@ -0,0 +1,42 @@
import { ApiError, request } from './client';
/** A private per-user taste signal on a manga. */
export type Reaction = 'like' | 'dislike';
export type MangaReaction = {
manga_id: string;
reaction: Reaction | null;
};
/** PUT /v1/mangas/:id/reaction — set (or switch) the user's reaction. */
export async function setReaction(mangaId: string, reaction: Reaction): Promise<MangaReaction> {
return request<MangaReaction>(`/v1/mangas/${encodeURIComponent(mangaId)}/reaction`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ reaction })
});
}
/** DELETE /v1/mangas/:id/reaction — clear the user's reaction (idempotent). */
export async function clearReaction(mangaId: string): Promise<void> {
await request<void>(`/v1/mangas/${encodeURIComponent(mangaId)}/reaction`, {
method: 'DELETE'
});
}
/**
* Returns the user's reaction for a manga, or `null` when they haven't
* reacted (or aren't signed in). Used by the detail page to seed the
* like/dislike toggle.
*/
export async function getMyReactionForManga(mangaId: string): Promise<Reaction | null> {
try {
const r = await request<MangaReaction>(
`/v1/me/reactions/${encodeURIComponent(mangaId)}`
);
return r.reaction;
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 404)) return null;
throw e;
}
}

View File

@@ -0,0 +1,17 @@
import { ApiError, request } from './client';
import type { MangaCard } from './mangas';
/**
* GET /v1/me/recommendations — content-based "Recommended for you" feed.
* Returns an empty list for guests (401) so callers can render nothing
* without special-casing auth.
*/
export async function listMyRecommendations(): Promise<MangaCard[]> {
try {
const r = await request<{ items: MangaCard[] }>('/v1/me/recommendations');
return r.items;
} catch (e) {
if (e instanceof ApiError && e.status === 401) return [];
throw e;
}
}

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { setReaction, clearReaction, type Reaction } from '$lib/api/reactions';
import ThumbsUp from '@lucide/svelte/icons/thumbs-up';
import ThumbsDown from '@lucide/svelte/icons/thumbs-down';
// Tri-state like/dislike toggle for a manga. Optimistic with rollback,
// mirroring the bookmark toggle. Rendered inside the detail page's
// signed-in action row, so it isn't independently login-gated.
let {
mangaId,
initial = null
}: {
mangaId: string;
initial?: Reaction | null;
} = $props();
// svelte-ignore state_referenced_locally
let current = $state<Reaction | null>(initial);
let busy = $state(false);
// The detail page reuses this component across manga -> manga navigation
// (similar / recommendation cards), so `current` must re-seed when the
// loader hands us a new manga's reaction. `mangaId` is referenced so the
// effect also re-runs when navigating between two never-reacted mangas
// (both `initial === null`).
$effect(() => {
mangaId;
current = initial;
});
// Click the active reaction to clear it; click the other to switch.
async function apply(next: Reaction) {
if (busy) return;
const prev = current;
const target: Reaction | null = current === next ? null : next;
current = target; // optimistic
busy = true;
try {
if (target === null) await clearReaction(mangaId);
else await setReaction(mangaId, target);
} catch {
current = prev; // rollback on failure
} finally {
busy = false;
}
}
</script>
<div class="reactions" role="group" aria-label="Rate this manga">
<button
type="button"
class="reaction"
class:active={current === 'like'}
aria-pressed={current === 'like'}
aria-label="Like"
title="Like"
disabled={busy}
onclick={() => apply('like')}
data-testid="reaction-like"
>
<ThumbsUp size={16} aria-hidden="true" />
</button>
<button
type="button"
class="reaction dislike"
class:active={current === 'dislike'}
aria-pressed={current === 'dislike'}
aria-label="Dislike"
title="Dislike"
disabled={busy}
onclick={() => apply('dislike')}
data-testid="reaction-dislike"
>
<ThumbsDown size={16} aria-hidden="true" />
</button>
</div>
<style>
.reactions {
display: inline-flex;
gap: var(--space-1);
}
.reaction {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
cursor: pointer;
transition:
background var(--transition),
border-color var(--transition),
color var(--transition);
}
.reaction:hover:not(:disabled) {
background: var(--surface-elevated);
}
.reaction:disabled {
opacity: 0.6;
cursor: default;
}
.reaction.active {
color: var(--primary-contrast);
background: var(--primary);
border-color: var(--primary);
}
.reaction.dislike.active {
color: var(--primary-contrast);
background: var(--danger);
border-color: var(--danger);
}
.reaction:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
const setReaction = vi.fn();
const clearReaction = vi.fn();
vi.mock('$lib/api/reactions', () => ({
setReaction: (...a: unknown[]) => setReaction(...a),
clearReaction: (...a: unknown[]) => clearReaction(...a)
}));
import ReactionButtons from './ReactionButtons.svelte';
afterEach(() => {
cleanup();
setReaction.mockReset();
clearReaction.mockReset();
});
const pressed = (testid: string) =>
screen.getByTestId(testid).getAttribute('aria-pressed');
describe('ReactionButtons', () => {
it('reflects the initial reaction', () => {
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
expect(pressed('reaction-like')).toBe('true');
expect(pressed('reaction-dislike')).toBe('false');
});
it('likes when nothing is set', async () => {
setReaction.mockResolvedValue({ manga_id: 'm1', reaction: 'like' });
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
await fireEvent.click(screen.getByTestId('reaction-like'));
expect(setReaction).toHaveBeenCalledWith('m1', 'like');
expect(pressed('reaction-like')).toBe('true');
});
it('clears when clicking the active reaction again', async () => {
clearReaction.mockResolvedValue(undefined);
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
await fireEvent.click(screen.getByTestId('reaction-like'));
expect(clearReaction).toHaveBeenCalledWith('m1');
expect(pressed('reaction-like')).toBe('false');
});
it('switches from like to dislike', async () => {
setReaction.mockResolvedValue({ manga_id: 'm1', reaction: 'dislike' });
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
await fireEvent.click(screen.getByTestId('reaction-dislike'));
expect(setReaction).toHaveBeenCalledWith('m1', 'dislike');
expect(pressed('reaction-dislike')).toBe('true');
expect(pressed('reaction-like')).toBe('false');
});
it('resyncs when navigated to a different manga', async () => {
// The detail page component is reused across /manga/A -> /manga/B
// navigations (e.g. clicking a similar or recommendation card), so
// new props must overwrite the locally-held reaction state.
const { rerender } = render(ReactionButtons, {
props: { mangaId: 'm1', initial: 'like' }
});
expect(pressed('reaction-like')).toBe('true');
await rerender({ mangaId: 'm2', initial: 'dislike' });
expect(pressed('reaction-dislike')).toBe('true');
expect(pressed('reaction-like')).toBe('false');
await rerender({ mangaId: 'm3', initial: null });
expect(pressed('reaction-like')).toBe('false');
expect(pressed('reaction-dislike')).toBe('false');
});
it('rolls back on failure', async () => {
setReaction.mockRejectedValue(new Error('boom'));
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
await fireEvent.click(screen.getByTestId('reaction-like'));
// Optimistic update reverted after the rejection.
await Promise.resolve();
expect(pressed('reaction-like')).toBe('false');
});
});

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import MangaCard from '$lib/components/MangaCard.svelte';
import type { MangaCard as MangaCardData } from '$lib/api/mangas';
// Horizontal "Recommended for you" shelf for the homepage, fed by the
// content-based /me/recommendations feed. Reuses the catalogue MangaCard.
// Rendered only when there are recommendations (the homepage owns that
// gate), so this has no empty state.
let {
mangas,
testid = 'recommendations-shelf'
}: {
mangas: MangaCardData[];
testid?: string;
} = $props();
</script>
<section class="shelf" aria-label="Recommended for you" data-testid={testid}>
<h2 class="shelf-title">Recommended for you</h2>
<ul class="rec-track" data-testid="{testid}-list">
{#each mangas as m (m.id)}
<MangaCard manga={m} authors={m.authors} genres={m.genres} testid="rec-card-{m.id}" />
{/each}
</ul>
</section>
<style>
.shelf {
margin: 0 0 var(--space-5);
}
.shelf-title {
font-size: var(--font-md);
font-weight: var(--weight-semibold);
margin: 0 0 var(--space-2);
}
.rec-track {
list-style: none;
display: flex;
gap: var(--space-4);
padding: 0 0 var(--space-2);
margin: 0;
overflow-x: auto;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
}
/* MangaCard renders an <li class="manga-card">; in this horizontal
scroller give each a fixed width instead of the grid's flexible track. */
.rec-track > :global(.manga-card) {
flex: 0 0 auto;
width: 150px;
scroll-snap-align: start;
}
</style>

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import RecommendationShelf from './RecommendationShelf.svelte';
import type { MangaCard } from '$lib/api/mangas';
afterEach(() => cleanup());
function manga(id: string, title: string): MangaCard {
return {
id,
title,
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: []
};
}
describe('RecommendationShelf', () => {
it('renders a card per recommendation under a heading', () => {
render(RecommendationShelf, {
props: { mangas: [manga('m1', 'Berserk'), manga('m2', 'Vinland')] }
});
expect(screen.getByRole('heading', { name: /recommended for you/i })).toBeTruthy();
expect(screen.getByTestId('rec-card-m1')).toBeTruthy();
expect(screen.getByTestId('rec-card-m2')).toBeTruthy();
expect(screen.getByText('Berserk')).toBeTruthy();
expect(screen.getByText('Vinland')).toBeTruthy();
});
});

View File

@@ -27,8 +27,10 @@
type ReadProgressSummary
} from '$lib/api/read_progress';
import { isCaughtUp } from '$lib/continueReading';
import { listMyRecommendations } from '$lib/api/recommendations';
import Chip from '$lib/components/Chip.svelte';
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
@@ -44,6 +46,7 @@
let mangas: MangaCardData[] = $state([]);
let continueEntries = $state<ReadProgressSummary[]>([]);
let recommendations = $state<MangaCardData[]>([]);
let search = $state('');
let sort = $state<MangaSort>(DEFAULT_SORT);
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
@@ -308,17 +311,24 @@
}
await hydrateFromUrl();
await load();
// Fetch the "Continue reading" shelf after the catalogue so the
// public browse path stays unauthenticated and unblocked. Returns
// empty for guests (401 swallowed), which hides the shelf.
try {
const progress = await listMyReadProgressOrEmpty();
// Fetch the personal shelves after the catalogue so the public browse
// path stays unauthenticated and unblocked. Both are independent
// `/me/*` calls, so fire them concurrently rather than in series.
// Each is isolated: a failure (or 401 for guests) hides its own shelf
// without touching the catalogue or the other shelf.
const [progressResult, recsResult] = await Promise.allSettled([
listMyReadProgressOrEmpty(),
listMyRecommendations()
]);
if (progressResult.status === 'fulfilled') {
// Drop finished series (read to the end, nothing new) — a
// "Continue reading" shelf is for what's still in progress.
continueEntries = progress.items.filter((e) => !isCaughtUp(e));
} catch {
// Never let a history hiccup break the catalogue — leave the
// shelf hidden.
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
}
if (recsResult.status === 'fulfilled') {
// Personal "Recommended for you" feed (empty for guests / no
// taste signals yet → shelf hidden).
recommendations = recsResult.value;
}
});
@@ -478,6 +488,10 @@
<ContinueReadingShelf entries={continueEntries} />
{/if}
{#if recommendations.length > 0}
<RecommendationShelf mangas={recommendations} />
{/if}
<form
onsubmit={onSubmit}
action="javascript:void(0)"

View File

@@ -20,6 +20,7 @@
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import ReactionButtons from '$lib/components/ReactionButtons.svelte';
import Sheet from '$lib/components/Sheet.svelte';
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus';
@@ -541,6 +542,7 @@
>
{mangaBookmark ? '★ Bookmarked' : '☆ Bookmark'}
</button>
<ReactionButtons mangaId={manga.id} initial={data.reaction} />
<button
type="button"
class="action"

View File

@@ -2,17 +2,22 @@ import { getManga, getSimilarMangas, type MangaCard } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
import { getMyReadProgressForManga } from '$lib/api/read_progress';
import { getMyReactionForManga } from '$lib/api/reactions';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, chapters, bookmarks, readProgress, similar] = await Promise.all([
const [manga, chapters, bookmarks, readProgress, reaction, similar] = await Promise.all([
getManga(params.id),
listChapters(params.id),
listMyBookmarksOrEmpty(),
// Null when guest or never-read — page handles both cases.
getMyReadProgressForManga(params.id),
// Null when guest or not reacted — seeds the like/dislike toggle.
// Non-critical: any failure degrades to an unset toggle, never a
// broken page.
getMyReactionForManga(params.id).catch(() => null),
// Recommendations are non-critical: a failure here must not break
// the detail page, so fall back to an empty list.
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
@@ -22,6 +27,7 @@ export const load: PageLoad = async ({ params }) => {
chapters: chapters.items,
bookmarks: bookmarks.items,
readProgress,
reaction,
similar
};
};