70
CONTEXT.md
70
CONTEXT.md
@@ -1,69 +1,17 @@
|
||||
# Movies Diary
|
||||
|
||||
A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
|
||||
A personal movie diary where users record each watching of a movie with a rating and an optional written review; reviews appear in diaries and social feeds shared with local and federated followers.
|
||||
|
||||
## Language
|
||||
|
||||
**Movie**:
|
||||
A film in the catalog, identified by title and release year. Optionally linked to an external metadata provider (e.g. TMDb) for enrichment. One Movie record is shared across all users — "Blade Runner (1982)" exists once regardless of how many people review it.
|
||||
_Avoid_: Film entry, title record
|
||||
|
||||
**Person**:
|
||||
Someone involved in making a movie — actor, director, crew member. Sourced from an external metadata provider and enriched with biographical data. Linked to Movies through cast/crew credits. Not a User — Person is movie-industry people only.
|
||||
_Avoid_: Celebrity, artist, talent
|
||||
|
||||
**Review**:
|
||||
A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched.
|
||||
_Avoid_: Diary entry, watch, log entry
|
||||
The record of a single watching of a movie: a rating, an optional written comment, when it was watched, and optionally how.
|
||||
_Avoid_: Diary entry, post
|
||||
|
||||
**Rating**:
|
||||
A 1–5 whole-star score given to a movie in a Review. No half-stars, no zero.
|
||||
_Avoid_: Score, grade, stars (as a noun for the value itself)
|
||||
**Diary Entry**:
|
||||
A Review together with the Movie it is about, as it appears in a diary or a feed.
|
||||
_Avoid_: Review (that is the record itself)
|
||||
|
||||
**WatchMedium**:
|
||||
The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
|
||||
_Avoid_: Source, format, venue, platform
|
||||
|
||||
**Watchlist**:
|
||||
A user's collection of movies they intend to watch. Each item is a simple bookmark — no priority or ordering. A movie leaves the watchlist implicitly when reviewed, or explicitly when removed.
|
||||
_Avoid_: Queue, backlog, to-watch list
|
||||
|
||||
**Goal**:
|
||||
A yearly target a user sets — e.g. "watch 50 movies in 2025." Progress is tracked automatically as reviews are logged. Currently only supports movie-count goals, but the model is designed for other goal types in the future.
|
||||
_Avoid_: Challenge, resolution, target
|
||||
|
||||
**WrapUp**:
|
||||
A generated summary report of viewing activity over a date range — statistics, trends, highlights, top directors/actors/genres. Can be personal (one user) or global (all users). Generated asynchronously. Shown to users as "Year in Review."
|
||||
_Avoid_: Stats page, recap, summary
|
||||
|
||||
**Insight**:
|
||||
A statement about a user's viewing behavior derived from their Reviews and linked Movie metadata, produced by correlation analysis. Always a comparison — relative to the user's own baseline (their average rating, their genre frequencies, their typical hours) — never a plain ranking or count. "You rate thrillers higher than your average" is an Insight; "thriller is your top genre" is a statistic.
|
||||
_Avoid_: Correlation (implementation), fun fact, finding
|
||||
|
||||
**User**:
|
||||
A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin.
|
||||
_Avoid_: Account, member, profile (as a synonym for the whole User)
|
||||
|
||||
**SocialIdentity**:
|
||||
The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote.
|
||||
_Avoid_: Actor, participant, social user
|
||||
|
||||
**Follow**:
|
||||
A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed.
|
||||
_Avoid_: Subscribe, connect, friend
|
||||
|
||||
**Feed**:
|
||||
A chronological timeline of reviews from users you follow — both local and federated. The main social surface of the app.
|
||||
_Avoid_: Timeline, activity stream, home
|
||||
|
||||
**WatchEvent**:
|
||||
An automatically detected viewing reported by an external source — currently Jellyfin and Plex via webhook, but conceptually any system that can report "this person watched this movie" (e.g. a cinema ticket service). Arrives in a pending state; the user confirms it (creating a Review) or dismisses it.
|
||||
_Avoid_: Playback event, webhook event, auto-import
|
||||
|
||||
**Import**:
|
||||
Bulk ingestion of reviews from an external file — Letterboxd CSV, IMDb CSV, or a generic JSON format. The user uploads a file, column mappings are applied, and reviews are created in batch.
|
||||
_Avoid_: Upload, migration, sync
|
||||
|
||||
**ImportProfile**:
|
||||
A saved set of column-to-field mappings for an Import. Reusable across imports and shareable between users.
|
||||
_Avoid_: Template, mapping preset, import config
|
||||
**Spoiler Flag**:
|
||||
An author-set marker on a Review stating that its entire comment reveals plot details.
|
||||
_Avoid_: Spoiler warning, spoiler tag, spoiler markup
|
||||
|
||||
@@ -49,6 +49,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
||||
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 1–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
|
||||
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
|
||||
- Spoiler Flag — authors mark a review as containing spoilers; the comment is veiled (blur + reveal) for every viewer except the author, withheld from RSS, and federates as `containsSpoilers` + an AP `summary` content warning so Mastodon-style clients also collapse it
|
||||
- Background poster fetching and storage (local filesystem or S3-compatible)
|
||||
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
|
||||
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
|
||||
|
||||
@@ -60,6 +60,12 @@ pub struct ReviewObject {
|
||||
/// Discriminator so Movies Diary instances detect this as a review Note.
|
||||
#[serde(default)]
|
||||
pub(crate) review: bool,
|
||||
/// Spoiler Flag. Movies-Diary peers veil the comment behind this boolean;
|
||||
/// other fediverse apps render `summary` as a content warning (ADR-0011).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub(crate) contains_spoilers: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) attachment: Vec<ApAttachment>,
|
||||
#[serde(default)]
|
||||
@@ -98,6 +104,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
String::new()
|
||||
};
|
||||
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
|
||||
let spoiler_flag = review.spoilers() && comment_text.is_some();
|
||||
let content = match &comment_text {
|
||||
Some(c) => format!(
|
||||
"{} {}{}\n{}\n{}",
|
||||
@@ -145,6 +152,8 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||
review: true,
|
||||
contains_spoilers: spoiler_flag,
|
||||
summary: spoiler_flag.then(|| "Contains spoilers".to_string()),
|
||||
attachment,
|
||||
tag,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct RemoteReviewUpdate<'a> {
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub poster_url: Option<&'a str>,
|
||||
pub watch_medium: Option<&'a str>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -157,6 +157,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
actor_url: actor_url_str,
|
||||
},
|
||||
watch_medium,
|
||||
spoilers: obj.contains_spoilers,
|
||||
});
|
||||
|
||||
self.review_store
|
||||
@@ -214,6 +215,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
watched_at: obj.watched_at.naive_utc(),
|
||||
poster_url: obj.poster_url.as_deref(),
|
||||
watch_medium: obj.watch_medium.as_deref(),
|
||||
spoilers: obj.contains_spoilers,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ fn review_to_ap_object_includes_two_hashtags() {
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
@@ -69,6 +70,7 @@ fn review_to_ap_object_has_public_addressing() {
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
});
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = review_to_ap_object(
|
||||
@@ -87,6 +89,74 @@ fn review_to_ap_object_has_public_addressing() {
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_carries_spoiler_flag_and_summary() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let ts = NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: Some(Comment::new("the mentor is the villain".into()).unwrap()),
|
||||
watched_at: ts,
|
||||
created_at: ts,
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(obj.contains_spoilers);
|
||||
assert_eq!(obj.summary.as_deref(), Some("Contains spoilers"));
|
||||
let json = serde_json::to_value(&obj).unwrap();
|
||||
assert_eq!(json["containsSpoilers"], serde_json::Value::Bool(true));
|
||||
assert_eq!(json["summary"], "Contains spoilers");
|
||||
|
||||
// A flag without a comment never travels: there is nothing to veil.
|
||||
let review_no_comment = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: None,
|
||||
watched_at: ts,
|
||||
created_at: ts,
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
let obj2 = review_to_ap_object(
|
||||
&review_no_comment,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/2".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(!obj2.contains_spoilers);
|
||||
assert!(obj2.summary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchlist_to_ap_object_has_public_addressing() {
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
|
||||
@@ -89,6 +89,7 @@ pub fn review_row_to_domain(
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
|
||||
@@ -112,6 +113,7 @@ pub fn review_row_to_domain(
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ fn stream_csv(
|
||||
use futures::StreamExt;
|
||||
let header = futures::stream::once(async {
|
||||
Ok(Bytes::from_static(
|
||||
b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
|
||||
b"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n",
|
||||
))
|
||||
});
|
||||
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
|
||||
@@ -80,9 +80,10 @@ fn csv_row(e: &DiaryEntry) -> String {
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string())
|
||||
.unwrap_or_default();
|
||||
let spoilers = e.review().spoilers();
|
||||
format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id
|
||||
"{},{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id, spoilers
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +104,7 @@ fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
|
||||
"comment": e.review().comment().map(|c| c.value().to_string()),
|
||||
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
||||
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
|
||||
"spoilers": e.review().spoilers(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ fn make_entry_full(
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
DiaryEntry::new(movie, review)
|
||||
@@ -80,9 +81,9 @@ async fn csv_has_header_and_one_row() {
|
||||
let bytes =
|
||||
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
|
||||
);
|
||||
assert!(text.starts_with(
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n"
|
||||
));
|
||||
assert!(text.contains("Inception"));
|
||||
assert!(text.contains("2010"));
|
||||
assert!(text.contains("Christopher Nolan"));
|
||||
@@ -149,7 +150,7 @@ async fn empty_entries_returns_csv_header_only() {
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert_eq!(
|
||||
text,
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9) ON CONFLICT DO NOTHING",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -58,6 +58,7 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -75,13 +76,14 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4
|
||||
WHERE ap_id = $5 AND remote_actor_url = $6",
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5
|
||||
WHERE ap_id = $6 AND remote_actor_url = $7",
|
||||
)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.spoilers)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -69,6 +69,7 @@ struct ReviewRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -95,6 +96,7 @@ impl ReviewRow {
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ struct DiaryRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -139,6 +142,7 @@ impl DiaryRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
@@ -226,7 +230,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -256,7 +261,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
|
||||
@@ -276,7 +282,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -56,7 +56,8 @@ impl PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY {}
|
||||
@@ -90,7 +91,8 @@ impl PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1
|
||||
@@ -178,7 +180,8 @@ impl PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1{remote_clause}{search_clause}
|
||||
@@ -336,6 +339,7 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium,
|
||||
r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
@@ -412,7 +416,8 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
watch_medium,
|
||||
spoilers
|
||||
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
@@ -434,7 +439,8 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
@@ -461,7 +467,8 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
@@ -521,6 +528,7 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium,
|
||||
r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) struct ReviewRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -91,6 +92,7 @@ impl ReviewRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +114,7 @@ pub(crate) struct DiaryRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -134,6 +137,7 @@ impl DiaryRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
@@ -156,6 +160,7 @@ pub(crate) struct FeedRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
@@ -179,6 +184,7 @@ impl FeedRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
let diary = DiaryEntry::new(movie, review);
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
|
||||
@@ -36,8 +36,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -48,6 +48,7 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
@@ -62,7 +63,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
watch_medium,
|
||||
spoilers
|
||||
FROM reviews WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
@@ -81,12 +83,13 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5",
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 WHERE id = $6",
|
||||
)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&watch_medium)
|
||||
.bind(review.spoilers())
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -112,7 +115,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
watch_medium,
|
||||
spoilers
|
||||
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
|
||||
@@ -22,9 +22,14 @@ impl RssFeedRenderer for RssAdapter {
|
||||
e.movie().title().value(),
|
||||
e.movie().release_year().value()
|
||||
);
|
||||
let description = match e.review().comment() {
|
||||
Some(c) => format!("{}/5 — {}", e.review().rating().value(), c.value()),
|
||||
None => format!("{}/5", e.review().rating().value()),
|
||||
let description = match (e.review().comment(), e.review().spoilers()) {
|
||||
(Some(_), true) => {
|
||||
format!("{}/5 — contains spoilers", e.review().rating().value())
|
||||
}
|
||||
(Some(c), false) => {
|
||||
format!("{}/5 — {}", e.review().rating().value(), c.value())
|
||||
}
|
||||
(None, _) => format!("{}/5", e.review().rating().value()),
|
||||
};
|
||||
let pub_date = e
|
||||
.review()
|
||||
|
||||
@@ -46,8 +46,8 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -58,6 +58,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -75,13 +76,14 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ?
|
||||
WHERE ap_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.spoilers)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -69,6 +69,7 @@ struct ReviewRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -95,6 +96,7 @@ impl ReviewRow {
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ struct DiaryRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -139,6 +142,7 @@ impl DiaryRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
@@ -212,7 +216,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
@@ -238,7 +242,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
@@ -254,7 +258,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE reviews ADD COLUMN spoilers INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -52,7 +52,7 @@ impl SqliteDiaryRepository {
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY {}
|
||||
@@ -82,7 +82,7 @@ impl SqliteDiaryRepository {
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ?
|
||||
@@ -158,7 +158,7 @@ impl SqliteDiaryRepository {
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?{remote_clause}{search_clause}
|
||||
@@ -305,7 +305,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
let select_sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
@@ -378,7 +378,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
@@ -396,7 +396,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
@@ -419,7 +419,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
Box::pin(async_stream::stream! {
|
||||
let mut rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
@@ -476,7 +476,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(crate) struct ReviewRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -95,6 +96,7 @@ impl ReviewRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ pub(crate) struct DiaryRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -138,6 +141,7 @@ impl DiaryRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
@@ -189,6 +193,7 @@ pub(crate) struct FeedRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
@@ -212,6 +217,7 @@ impl FeedRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
let diary = DiaryEntry::new(movie, review);
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
|
||||
@@ -36,8 +36,8 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -48,6 +48,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
@@ -58,7 +59,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
@@ -77,12 +78,13 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ? WHERE id = ?",
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?",
|
||||
)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&watch_medium)
|
||||
.bind(review.spoilers())
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -104,7 +106,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE user_id = ? ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
|
||||
@@ -46,8 +46,15 @@
|
||||
<span class="watch-medium">{{ wm.label() }}</span>
|
||||
{% endif %}
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="feed-meta">
|
||||
{% match entry.review().source() %}
|
||||
{% when ReviewSource::Remote with { actor_url } %}
|
||||
|
||||
@@ -152,8 +152,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="embed-comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="embed-comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="embed-watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -130,8 +130,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="feed-meta">
|
||||
{% match entry.review().source() %}
|
||||
{% when ReviewSource::Remote with { actor_url } %}
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
Comment<br>
|
||||
<textarea name="comment"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Contains spoilers <input type="checkbox" name="spoilers" value="true">
|
||||
</label>
|
||||
<label>
|
||||
Watched via <span class="optional">(optional)</span><br>
|
||||
<select name="watch_medium">
|
||||
|
||||
@@ -220,8 +220,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
|
||||
{% if ctx.is_current_user(entry.review().user_id().value()) %}
|
||||
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct LogReviewRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -75,6 +77,8 @@ pub struct EditReviewRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<Option<String>>)]
|
||||
pub watch_medium: Option<Option<domain::value_objects::WatchMedium>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spoilers: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_export_format() -> String {
|
||||
|
||||
@@ -92,6 +92,8 @@ pub struct ReviewDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -112,6 +114,7 @@ pub struct MovieStatsDto {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct SocialReviewDto {
|
||||
pub user_display: String,
|
||||
pub user_id: Uuid,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
@@ -119,6 +122,8 @@ pub struct SocialReviewDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct LogReviewCommand {
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
pub struct DeleteReviewCommand {
|
||||
@@ -31,6 +32,7 @@ pub struct EditReviewCommand {
|
||||
pub comment: Option<Option<String>>,
|
||||
pub watched_at: Option<NaiveDateTime>,
|
||||
pub watch_medium: Option<Option<WatchMedium>>,
|
||||
pub spoilers: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -34,6 +34,7 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<()
|
||||
.transpose()?,
|
||||
watched_at: cmd.watched_at,
|
||||
watch_medium: cmd.watch_medium,
|
||||
spoilers: cmd.spoilers,
|
||||
});
|
||||
|
||||
deps.review.update_review(&updated).await?;
|
||||
|
||||
@@ -73,6 +73,7 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
comment,
|
||||
cmd.watched_at,
|
||||
cmd.watch_medium,
|
||||
cmd.spoilers,
|
||||
)?;
|
||||
self.review_repo.save_review(&review).await?;
|
||||
let review_event = DomainEvent::ReviewLogged {
|
||||
|
||||
@@ -33,6 +33,7 @@ fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
||||
None,
|
||||
Utc::now().naive_utc(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ fn make_review(user_id: UserId) -> Review {
|
||||
Some(Comment::new("original comment".into()).unwrap()),
|
||||
Utc::now().naive_utc(),
|
||||
Some(WatchMedium::Streaming),
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -62,6 +63,7 @@ async fn edit_own_review_updates_rating() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -87,6 +89,7 @@ async fn edit_nonexistent_review_returns_not_found() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -108,6 +111,7 @@ async fn edit_other_users_review_returns_forbidden() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -129,6 +133,7 @@ async fn edit_publishes_review_updated_event() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -155,6 +160,7 @@ async fn edit_sets_watch_medium() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: Some(Some(WatchMedium::Cinema)),
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn user_feed_carries_author_and_entries() {
|
||||
None,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
diary.set_diary_page(Paginated {
|
||||
|
||||
@@ -67,6 +67,7 @@ async fn test_log_review_creates_movie_and_review() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
@@ -100,6 +101,7 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
@@ -122,6 +124,7 @@ async fn test_log_review_with_invalid_rating_fails() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
let result = log_review::execute(&logger, cmd).await;
|
||||
assert!(result.is_err(), "rating > 5 should fail");
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn logs_review_with_manual_movie() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -106,6 +107,7 @@ async fn removes_from_watchlist_on_review() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -144,6 +146,7 @@ async fn logs_review_with_existing_movie_by_id() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -173,6 +176,7 @@ async fn existing_movie_not_found_returns_error() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
assert!(logger.log_review(cmd).await.is_err());
|
||||
@@ -199,6 +203,7 @@ async fn invalid_rating_returns_error() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
let result = logger.log_review(cmd).await;
|
||||
@@ -229,6 +234,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -296,6 +302,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
@@ -119,5 +119,6 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
|
||||
comment: row.comment.clone(),
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub async fn execute(
|
||||
comment: c.comment,
|
||||
watched_at: *event.watched_at(),
|
||||
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
deps.review_logger.log_review(review_cmd).await?;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct PersistedReview {
|
||||
pub created_at: NaiveDateTime,
|
||||
pub source: ReviewSource,
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -34,6 +35,7 @@ pub struct ReviewEdit {
|
||||
pub comment: Option<Option<Comment>>,
|
||||
pub watched_at: Option<NaiveDateTime>,
|
||||
pub watch_medium: Option<Option<WatchMedium>>,
|
||||
pub spoilers: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -47,6 +49,7 @@ pub struct Review {
|
||||
created_at: NaiveDateTime,
|
||||
source: ReviewSource,
|
||||
watch_medium: Option<WatchMedium>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
@@ -57,6 +60,7 @@ impl Review {
|
||||
comment: Option<Comment>,
|
||||
watched_at: NaiveDateTime,
|
||||
watch_medium: Option<WatchMedium>,
|
||||
spoilers: bool,
|
||||
) -> Result<Self, DomainError> {
|
||||
Ok(Self {
|
||||
id: ReviewId::generate(),
|
||||
@@ -68,6 +72,7 @@ impl Review {
|
||||
created_at: Utc::now().naive_utc(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium,
|
||||
spoilers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +87,7 @@ impl Review {
|
||||
created_at: row.created_at,
|
||||
source: row.source,
|
||||
watch_medium: row.watch_medium,
|
||||
spoilers: row.spoilers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +108,7 @@ impl Review {
|
||||
Some(wm) => wm,
|
||||
None => self.watch_medium,
|
||||
},
|
||||
spoilers: edit.spoilers.unwrap_or(self.spoilers),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +139,9 @@ impl Review {
|
||||
pub fn watch_medium(&self) -> Option<&WatchMedium> {
|
||||
self.watch_medium.as_ref()
|
||||
}
|
||||
pub fn spoilers(&self) -> bool {
|
||||
self.spoilers
|
||||
}
|
||||
pub fn stars(&self) -> [bool; 5] {
|
||||
let r = self.rating.value();
|
||||
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]
|
||||
|
||||
@@ -79,6 +79,7 @@ fn make_review() -> Review {
|
||||
Some(Comment::new("great".into()).unwrap()),
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -114,10 +115,12 @@ fn review_from_persistence() {
|
||||
actor_url: "https://example.com/actor".into(),
|
||||
},
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
assert_eq!(*r.id(), id);
|
||||
assert!(r.is_remote());
|
||||
assert_eq!(r.comment(), None);
|
||||
assert!(r.spoilers());
|
||||
}
|
||||
|
||||
// ── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ fn make_review(watch_medium: Option<WatchMedium>) -> Review {
|
||||
Some(Comment::new("great film".into()).unwrap()),
|
||||
NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
watch_medium,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -71,6 +72,23 @@ fn apply_edit_clears_watch_medium() {
|
||||
assert_eq!(edited.watch_medium(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_toggles_spoilers() {
|
||||
let original = make_review(None);
|
||||
let flag_on = original.apply_edit(ReviewEdit {
|
||||
spoilers: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(flag_on.spoilers());
|
||||
let flag_off = flag_on.apply_edit(ReviewEdit {
|
||||
spoilers: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!flag_off.spoilers());
|
||||
let untouched = original.apply_edit(ReviewEdit::default());
|
||||
assert!(!untouched.spoilers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_no_changes_preserves_all() {
|
||||
let original = make_review(Some(WatchMedium::TV));
|
||||
|
||||
@@ -29,6 +29,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime)
|
||||
None,
|
||||
watched_at,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct LogReviewForm {
|
||||
pub watched_at: String,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub watch_medium: Option<String>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
@@ -173,6 +175,7 @@ pub struct LogReviewData {
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -212,6 +215,7 @@ impl TryFrom<LogReviewForm> for LogReviewData {
|
||||
comment: form.comment,
|
||||
watched_at,
|
||||
watch_medium,
|
||||
spoilers: form.spoilers,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -230,6 +234,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -249,6 +254,7 @@ impl LogReviewData {
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ pub async fn patch_review(
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
};
|
||||
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
|
||||
Ok(StatusCode::OK)
|
||||
|
||||
@@ -168,11 +168,13 @@ pub async fn get_movie_detail(
|
||||
.iter()
|
||||
.map(|e| SocialReviewDto {
|
||||
user_display: e.user_display_name().to_string(),
|
||||
user_id: e.review().user_id().value(),
|
||||
rating: e.review().rating().value(),
|
||||
comment: e.review().comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
|
||||
is_federated: e.review().is_remote(),
|
||||
watch_medium: e.review().watch_medium().copied(),
|
||||
spoilers: e.review().spoilers(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: result.reviews.total_count,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
|
||||
comment: review.comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
|
||||
watch_medium: review.watch_medium().copied(),
|
||||
spoilers: review.spoilers(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ fn make_form(watched_at: &str) -> LogReviewForm {
|
||||
comment: None,
|
||||
watched_at: watched_at.to_string(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
csrf_token: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -24,6 +25,7 @@ fn make_request(watched_at: &str) -> LogReviewRequest {
|
||||
comment: None,
|
||||
watched_at: watched_at.to_string(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
|
||||
},
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -829,6 +830,7 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
comment,
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
app.loading = true;
|
||||
return vec![Command::CreateReview(req)];
|
||||
|
||||
@@ -55,6 +55,7 @@ fn diary_entry() -> DiaryEntryDto {
|
||||
comment: None,
|
||||
watched_at: "1999-03-31T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -383,6 +384,7 @@ fn bulk_import_all_with_valid_rows_returns_import_next_command() {
|
||||
comment: None,
|
||||
watched_at: "1999-03-31T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
@@ -406,6 +408,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-01T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
},
|
||||
LogReviewRequest {
|
||||
external_metadata_id: None,
|
||||
@@ -416,6 +419,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-02T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
},
|
||||
];
|
||||
m.bulk_import.results = vec![None, None];
|
||||
@@ -445,6 +449,7 @@ fn bulk_item_done_last_item_transitions_to_done() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-01T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}];
|
||||
m.bulk_import.results = vec![None];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn log_review_request_skips_none_fields() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-15T20:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("external_metadata_id"));
|
||||
@@ -44,6 +45,7 @@ fn log_review_request_includes_director_when_set() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-15T20:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));
|
||||
|
||||
9
docs/adr/0011-spoiler-flag-federation-encoding.md
Normal file
9
docs/adr/0011-spoiler-flag-federation-encoding.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Spoiler Flag federates as both a boolean field and a summary content warning
|
||||
|
||||
Reviews marked with the Spoiler Flag serialize on the ActivityPub Note as `containsSpoilers: true` alongside `summary: "Contains spoilers"`. The boolean is the source of truth for Movies-Diary peers; the summary exists so Mastodon-class clients — which render any Note summary as a collapsed content warning — also protect their users instead of showing the spoiler raw. Inbound parsing trusts the boolean only (review Notes always originate from Movies-Diary instances, gated by the `review: true` discriminator), so unrelated summaries from other apps are never misread as spoiler flags.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Boolean field only** — consistent with `watchMedium`, but Mastodon followers saw the full spoiler text raw; the feature silently failed for half the audience.
|
||||
- **Summary-only** (presence = flag) — rejected because "any summary means spoiler" breaks if reviews ever gain titles or other apps content-warn for other reasons.
|
||||
- **Emitting neither and relying on veiling at the source** — rejected; the flag must travel with the object or remote instances cannot protect their own viewers.
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Globe, Users } from "lucide-react"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import type { SocialReviewDto } from "@/features/movies"
|
||||
|
||||
export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items: SocialReviewDto[] }; onShowDetail?: (review: SocialReviewDto) => void }) {
|
||||
type CommunityReviewsProps = {
|
||||
reviews: { items: SocialReviewDto[] }
|
||||
onShowDetail?: (review: SocialReviewDto) => void
|
||||
}
|
||||
|
||||
export function CommunityReviews({ reviews, onShowDetail }: CommunityReviewsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -17,7 +23,9 @@ export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items:
|
||||
<EmptyState icon={Users} title={t("movie.noReviews")} description={t("movie.beFirst")} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reviews.items.map((r, i) => (
|
||||
{reviews.items.map((r, i) => {
|
||||
const veiled = !!r.spoilers
|
||||
return (
|
||||
<Card key={i} size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -36,17 +44,22 @@ export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items:
|
||||
</CardHeader>
|
||||
{r.comment && (
|
||||
<CardContent>
|
||||
{veiled ? (
|
||||
<SpoilerVeil content={r.comment} className="text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail ? () => onShowDetail(r) : undefined}
|
||||
onClick={veiled ? undefined : onShowDetail ? () => onShowDetail(r) : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail(r) : undefined}
|
||||
>{r.comment}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import type { MovieDto } from "@/lib/api/common"
|
||||
@@ -11,10 +12,13 @@ type MovieCardProps = {
|
||||
subtitle?: React.ReactNode
|
||||
variant?: "compact" | "full"
|
||||
action?: React.ReactNode
|
||||
/** Blur the comment until revealed — set for every viewer except the author. */
|
||||
spoilers?: boolean
|
||||
onShowDetail?: () => void
|
||||
}
|
||||
|
||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, onShowDetail }: MovieCardProps) {
|
||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, spoilers, onShowDetail }: MovieCardProps) {
|
||||
const veiled = !!spoilers && !!comment
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<Link to="/movies/$id" params={{ id: movie.id }} className="glass flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors active:bg-muted/50">
|
||||
@@ -25,6 +29,9 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
{comment && (
|
||||
veiled ? (
|
||||
<SpoilerVeil content={comment} className="truncate text-xs text-muted-foreground/70" />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground/70"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
@@ -32,6 +39,7 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>{comment}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
||||
@@ -51,6 +59,9 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
|
||||
{rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>}
|
||||
{comment && (
|
||||
veiled ? (
|
||||
<SpoilerVeil content={comment} className="mt-1 line-clamp-2 text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
@@ -58,6 +69,7 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>{comment}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
|
||||
|
||||
@@ -179,6 +179,7 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
||||
comment={e.review.comment}
|
||||
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||
variant="compact"
|
||||
spoilers={e.review.spoilers}
|
||||
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TimeAgo } from "@/components/time-ago"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
@@ -21,6 +22,8 @@ type ReviewCardProps = {
|
||||
}
|
||||
|
||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit, onShowDetail }: ReviewCardProps) {
|
||||
const veil = !!review.spoilers
|
||||
const showDetail = veil ? undefined : onShowDetail
|
||||
const card = (
|
||||
<Card size="sm">
|
||||
<CardContent className="flex gap-3">
|
||||
@@ -61,15 +64,19 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
||||
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||
</div>
|
||||
{review.comment && (
|
||||
veil ? (
|
||||
<SpoilerVeil content={review.comment} className="mt-1 text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
role={showDetail ? "button" : undefined}
|
||||
tabIndex={showDetail ? 0 : undefined}
|
||||
onClick={showDetail}
|
||||
onKeyDown={showDetail ? (e) => e.key === "Enter" && showDetail() : undefined}
|
||||
>
|
||||
{review.comment}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VisuallyHidden } from "radix-ui"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { shortDate } from "@/lib/date"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
@@ -40,9 +41,13 @@ export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName
|
||||
</div>
|
||||
|
||||
{review.comment && (
|
||||
review.spoilers ? (
|
||||
<SpoilerVeil content={review.comment} className="text-sm leading-relaxed" />
|
||||
) : (
|
||||
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{review.comment}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
@@ -19,6 +21,8 @@ type ReviewFormFieldsProps = {
|
||||
onWatchedAtChange: (v: Date) => void
|
||||
watchMedium?: string
|
||||
onWatchMediumChange: (v: string | undefined) => void
|
||||
spoilers: boolean
|
||||
onSpoilersChange: (v: boolean) => void
|
||||
}
|
||||
|
||||
export function ReviewFormFields({
|
||||
@@ -30,6 +34,8 @@ export function ReviewFormFields({
|
||||
onWatchedAtChange,
|
||||
watchMedium,
|
||||
onWatchMediumChange,
|
||||
spoilers,
|
||||
onSpoilersChange,
|
||||
}: ReviewFormFieldsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -42,6 +48,17 @@ export function ReviewFormFields({
|
||||
|
||||
<AutoGrowTextarea value={comment} onChange={onCommentChange} placeholder={t("logReview.commentPlaceholder")} className="mb-5" />
|
||||
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="review-spoilers"
|
||||
checked={spoilers}
|
||||
onCheckedChange={(v) => onSpoilersChange(v === true)}
|
||||
/>
|
||||
<Label htmlFor="review-spoilers" className="text-xs font-normal text-muted-foreground">
|
||||
{t("review.containsSpoilers")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
||||
<Popover modal>
|
||||
|
||||
@@ -52,6 +52,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
const [comment, setComment] = useState("")
|
||||
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
|
||||
const [watchMedium, setWatchMedium] = useState<string | undefined>()
|
||||
const [spoilers, setSpoilers] = useState(false)
|
||||
const logMutation = useLogReview()
|
||||
|
||||
function reset() {
|
||||
@@ -60,6 +61,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
setComment("")
|
||||
setWatchedAt(new Date())
|
||||
setWatchMedium(undefined)
|
||||
setSpoilers(false)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -79,6 +81,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
comment: comment || undefined,
|
||||
watched_at: formatWatchedAt(watchedAt),
|
||||
watch_medium: watchMedium,
|
||||
spoilers: spoilers || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -118,6 +121,8 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
onWatchedAtChange={setWatchedAt}
|
||||
watchMedium={watchMedium}
|
||||
onWatchMediumChange={setWatchMedium}
|
||||
spoilers={spoilers}
|
||||
onSpoilersChange={setSpoilers}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
|
||||
@@ -148,6 +153,7 @@ function EditMode({
|
||||
const [watchedAt, setWatchedAt] = useState<Date>(() => parseWatchedAt(review.watched_at))
|
||||
const [dateChanged, setDateChanged] = useState(false)
|
||||
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
|
||||
const [spoilers, setSpoilers] = useState(review.spoilers ?? false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const editMutation = useEditReview()
|
||||
const deleteMutation = useDeleteReview()
|
||||
@@ -166,6 +172,7 @@ function EditMode({
|
||||
if (newComment !== (review.comment ?? null)) data.comment = newComment
|
||||
if (dateChanged) data.watched_at = formatWatchedAt(watchedAt, isDateOnly(review.watched_at))
|
||||
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
|
||||
if (spoilers !== (review.spoilers ?? false)) data.spoilers = spoilers
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
toast.info(t("editReview.noChanges"))
|
||||
@@ -206,6 +213,8 @@ function EditMode({
|
||||
onWatchedAtChange={handleDateChange}
|
||||
watchMedium={watchMedium}
|
||||
onWatchMediumChange={setWatchMedium}
|
||||
spoilers={spoilers}
|
||||
onSpoilersChange={setSpoilers}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
|
||||
|
||||
43
spa/src/components/spoiler-veil.tsx
Normal file
43
spa/src/components/spoiler-veil.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SpoilerVeilProps = {
|
||||
content: string
|
||||
/** Layout classes for the veiled text (line clamps, sizing, color). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides review text behind a blur with a reveal button — shown to every
|
||||
* viewer, the author included. Once revealed, the text stays visible.
|
||||
*/
|
||||
export function SpoilerVeil({ content, className }: SpoilerVeilProps) {
|
||||
const { t } = useTranslation()
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
if (revealed) {
|
||||
return <span className={cn("block whitespace-pre-wrap", className)}>{content}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative block" aria-label={t("review.containsSpoilers")}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("pointer-events-none block select-none blur-[5px]", className)}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute inset-y-0 left-0 h-6 self-center px-2 text-[10px]"
|
||||
onClick={() => setRevealed(true)}
|
||||
>
|
||||
{t("review.showSpoilers")}
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { TrendingUp } from "lucide-react"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { shortDate } from "@/lib/date"
|
||||
import type { ReviewHistoryResponse } from "@/features/movies"
|
||||
|
||||
@@ -24,7 +25,11 @@ export function ViewingHistory({ history }: { history: ReviewHistoryResponse })
|
||||
<div>
|
||||
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
|
||||
{v.comment && (
|
||||
v.spoilers ? (
|
||||
<SpoilerVeil content={v.comment} className="mt-0.5 text-xs text-muted-foreground line-clamp-1" />
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<StarDisplay rating={v.rating} size="xs" />
|
||||
|
||||
@@ -32,6 +32,7 @@ export const logReviewRequestSchema = z.object({
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
watch_medium: z.string().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
|
||||
|
||||
@@ -40,6 +41,7 @@ export const editReviewRequestSchema = z.object({
|
||||
comment: z.string().nullable().optional(),
|
||||
watched_at: z.string().optional(),
|
||||
watch_medium: z.string().nullable().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type EditReviewRequest = z.infer<typeof editReviewRequestSchema>
|
||||
|
||||
|
||||
@@ -26,11 +26,13 @@ export type MovieStatsDto = z.infer<typeof movieStatsDtoSchema>
|
||||
|
||||
export const socialReviewDtoSchema = z.object({
|
||||
user_display: z.string(),
|
||||
user_id: z.string(),
|
||||
rating: z.number(),
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
is_federated: z.boolean(),
|
||||
watch_medium: z.string().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type SocialReviewDto = z.infer<typeof socialReviewDtoSchema>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const reviewDtoSchema = z.object({
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
watch_medium: z.string().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type ReviewDto = z.infer<typeof reviewDtoSchema>
|
||||
|
||||
|
||||
@@ -353,6 +353,10 @@
|
||||
"saved": "{{title}} updated!",
|
||||
"noChanges": "No changes to save"
|
||||
},
|
||||
"review": {
|
||||
"containsSpoilers": "Contains spoilers",
|
||||
"showSpoilers": "Show"
|
||||
},
|
||||
"logReview": {
|
||||
"title": "Log Review",
|
||||
"yourRating": "Your Rating",
|
||||
|
||||
@@ -105,7 +105,10 @@ function MovieDetailPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CommunityReviews reviews={reviews} onShowDetail={setDetailReview} />
|
||||
<CommunityReviews
|
||||
reviews={reviews}
|
||||
onShowDetail={setDetailReview}
|
||||
/>
|
||||
|
||||
{history && <ViewingHistory history={history} />}
|
||||
|
||||
@@ -114,7 +117,7 @@ function MovieDetailPage() {
|
||||
open={!!detailReview}
|
||||
onOpenChange={(open) => !open && setDetailReview(null)}
|
||||
movie={movie}
|
||||
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium }}
|
||||
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium, spoilers: detailReview.spoilers }}
|
||||
userName={detailReview.user_display}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user