Compare commits
5 Commits
9c4a93c058
...
ef8d226ba6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8d226ba6 | ||
|
|
19db66a845 | ||
|
|
d6a109df2d | ||
|
|
bb833c7e71 | ||
|
|
258a536254 |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.105.0"
|
||||
version = "0.109.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.105.0"
|
||||
version = "0.109.1"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
15
backend/migrations/0035_manga_reactions.sql
Normal file
15
backend/migrations/0035_manga_reactions.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
69
backend/src/api/reactions.rs
Normal file
69
backend/src/api/reactions.rs
Normal 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 }))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
40
backend/src/domain/reaction.rs
Normal file
40
backend/src/domain/reaction.rs
Normal 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>,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
66
backend/src/repo/reaction.rs
Normal file
66
backend/src/repo/reaction.rs
Normal 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)))
|
||||
}
|
||||
133
backend/tests/api_reactions.rs
Normal file
133
backend/tests/api_reactions.rs
Normal 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);
|
||||
}
|
||||
182
backend/tests/api_recommendations.rs
Normal file
182
backend/tests/api_recommendations.rs
Normal 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);
|
||||
}
|
||||
85
frontend/e2e/reaction-buttons.spec.ts
Normal file
85
frontend/e2e/reaction-buttons.spec.ts
Normal 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');
|
||||
});
|
||||
59
frontend/e2e/recommendations-shelf.spec.ts
Normal file
59
frontend/e2e/recommendations-shelf.spec.ts
Normal 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);
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.105.0",
|
||||
"version": "0.109.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
42
frontend/src/lib/api/reactions.ts
Normal file
42
frontend/src/lib/api/reactions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
frontend/src/lib/api/recommendations.ts
Normal file
17
frontend/src/lib/api/recommendations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
127
frontend/src/lib/components/ReactionButtons.svelte
Normal file
127
frontend/src/lib/components/ReactionButtons.svelte
Normal 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>
|
||||
80
frontend/src/lib/components/ReactionButtons.svelte.test.ts
Normal file
80
frontend/src/lib/components/ReactionButtons.svelte.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/components/RecommendationShelf.svelte
Normal file
56
frontend/src/lib/components/RecommendationShelf.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user