feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s
Some checks failed
CI / Check / Test (push) Failing after 27m0s
- WatchMedium enum (cinema/streaming/tv/physical_media/download/media_server/other) - PATCH /api/v1/reviews/:id partial update (rating, comment, watched_at, watch_medium) - edit_review use case w/ ownership + remote review guard, best-effort AP Update broadcast - SPA: icon picker, edit sheet (long-press mobile / pencil desktop), watch medium badge - shared ReviewFormFields, EditableContextMenu, parse_watched_at/format_watched_at - deploy.sh parameterized (--features, --tag), CORS allows PATCH - CONTEXT.md glossary, ADR-0001 general review editing
This commit is contained in:
13
CONTEXT.md
Normal file
13
CONTEXT.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Movies Diary
|
||||||
|
|
||||||
|
A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**WatchMedium**:
|
||||||
|
The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
|
||||||
|
_Avoid_: Source, format, venue, platform
|
||||||
@@ -132,6 +132,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
source: ReviewSource::Remote {
|
source: ReviewSource::Remote {
|
||||||
actor_url: actor_url_str,
|
actor_url: actor_url_str,
|
||||||
},
|
},
|
||||||
|
watch_medium: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn review_to_ap_object_includes_two_hashtags() {
|
|||||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
source: ReviewSource::Local,
|
source: ReviewSource::Local,
|
||||||
|
watch_medium: None,
|
||||||
});
|
});
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
@@ -67,6 +68,7 @@ fn review_to_ap_object_has_public_addressing() {
|
|||||||
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
source: ReviewSource::Local,
|
source: ReviewSource::Local,
|
||||||
|
watch_medium: None,
|
||||||
});
|
});
|
||||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ fn make_entry_full(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.and_hms_opt(0, 0, 0)
|
.and_hms_opt(0, 0, 0)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
DiaryEntry::new(movie, review)
|
DiaryEntry::new(movie, review)
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ impl ReviewRow {
|
|||||||
watched_at,
|
watched_at,
|
||||||
created_at,
|
created_at,
|
||||||
source,
|
source,
|
||||||
|
watch_medium: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;
|
||||||
@@ -60,7 +60,8 @@ impl PostgresDiaryRepository {
|
|||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
r.remote_actor_url
|
r.remote_actor_url,
|
||||||
|
r.watch_medium
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
@@ -93,7 +94,8 @@ impl PostgresDiaryRepository {
|
|||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
r.remote_actor_url
|
r.remote_actor_url,
|
||||||
|
r.watch_medium
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.movie_id = $1
|
WHERE r.movie_id = $1
|
||||||
@@ -178,7 +180,8 @@ impl PostgresDiaryRepository {
|
|||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
r.remote_actor_url
|
r.remote_actor_url,
|
||||||
|
r.watch_medium
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1{remote_clause}{search_clause}
|
WHERE r.user_id = $1{remote_clause}{search_clause}
|
||||||
@@ -339,6 +342,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
r.remote_actor_url,
|
r.remote_actor_url,
|
||||||
|
r.watch_medium,
|
||||||
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
|
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
@@ -432,7 +436,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
r.remote_actor_url
|
r.remote_actor_url,
|
||||||
|
r.watch_medium
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1
|
WHERE r.user_id = $1
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ pub(crate) struct ReviewRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReviewRow {
|
impl ReviewRow {
|
||||||
@@ -105,6 +106,7 @@ impl ReviewRow {
|
|||||||
None => ReviewSource::Local,
|
None => ReviewSource::Local,
|
||||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||||
};
|
};
|
||||||
|
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||||
Ok(Review::from_persistence(PersistedReview {
|
Ok(Review::from_persistence(PersistedReview {
|
||||||
id,
|
id,
|
||||||
movie_id,
|
movie_id,
|
||||||
@@ -114,6 +116,7 @@ impl ReviewRow {
|
|||||||
watched_at,
|
watched_at,
|
||||||
created_at,
|
created_at,
|
||||||
source,
|
source,
|
||||||
|
watch_medium,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +137,7 @@ pub(crate) struct DiaryRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiaryRow {
|
impl DiaryRow {
|
||||||
@@ -156,6 +160,7 @@ impl DiaryRow {
|
|||||||
watched_at: self.watched_at,
|
watched_at: self.watched_at,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
remote_actor_url: self.remote_actor_url,
|
remote_actor_url: self.remote_actor_url,
|
||||||
|
watch_medium: self.watch_medium,
|
||||||
}
|
}
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(DiaryEntry::new(movie, review))
|
Ok(DiaryEntry::new(movie, review))
|
||||||
@@ -178,6 +183,7 @@ pub(crate) struct FeedRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
pub user_email: String,
|
pub user_email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +204,7 @@ impl FeedRow {
|
|||||||
watched_at: self.watched_at,
|
watched_at: self.watched_at,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
remote_actor_url: self.remote_actor_url,
|
remote_actor_url: self.remote_actor_url,
|
||||||
|
watch_medium: self.watch_medium,
|
||||||
}
|
}
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(FeedEntry::new(diary, self.user_email))
|
Ok(FeedEntry::new(diary, self.user_email))
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
};
|
};
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
"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)",
|
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
@@ -52,6 +52,7 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
.bind(&watched_at)
|
.bind(&watched_at)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.bind(&remote_actor_url)
|
.bind(&remote_actor_url)
|
||||||
|
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
@@ -71,7 +72,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
"SELECT id, movie_id, user_id, rating, comment,
|
"SELECT id, movie_id, user_id, rating, comment,
|
||||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
remote_actor_url
|
remote_actor_url,
|
||||||
|
watch_medium
|
||||||
FROM reviews WHERE id = $1",
|
FROM reviews WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
@@ -82,6 +84,28 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||||
|
let id = review.id().value().to_string();
|
||||||
|
let rating = review.rating().value() as i64;
|
||||||
|
let comment = review.comment().map(|c| c.value().to_string());
|
||||||
|
let watched_at = datetime_to_str(review.watched_at());
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
.bind(rating)
|
||||||
|
.bind(&comment)
|
||||||
|
.bind(&watched_at)
|
||||||
|
.bind(&watch_medium)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||||
let id = review_id.value().to_string();
|
let id = review_id.value().to_string();
|
||||||
sqlx::query("DELETE FROM reviews WHERE id = $1")
|
sqlx::query("DELETE FROM reviews WHERE id = $1")
|
||||||
@@ -98,7 +122,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
"SELECT id, movie_id, user_id, rating, comment,
|
"SELECT id, movie_id, user_id, rating, comment,
|
||||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
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,
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
remote_actor_url
|
remote_actor_url,
|
||||||
|
watch_medium
|
||||||
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
|
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ impl ReviewRow {
|
|||||||
watched_at,
|
watched_at,
|
||||||
created_at,
|
created_at,
|
||||||
source,
|
source,
|
||||||
|
watch_medium: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
crates/adapters/sqlite/migrations/0029_watch_medium.sql
Normal file
1
crates/adapters/sqlite/migrations/0029_watch_medium.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;
|
||||||
@@ -57,7 +57,7 @@ impl SqliteDiaryRepository {
|
|||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
@@ -87,7 +87,7 @@ impl SqliteDiaryRepository {
|
|||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.movie_id = ?
|
WHERE r.movie_id = ?
|
||||||
@@ -161,7 +161,7 @@ impl SqliteDiaryRepository {
|
|||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = ?{remote_clause}{search_clause}
|
WHERE r.user_id = ?{remote_clause}{search_clause}
|
||||||
@@ -308,7 +308,7 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
let select_sql = format!(
|
let select_sql = format!(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
|
||||||
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
|
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
@@ -395,7 +395,7 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
let uid = user_id.value().to_string();
|
let uid = user_id.value().to_string();
|
||||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = ?
|
WHERE r.user_id = ?
|
||||||
@@ -418,7 +418,7 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
Box::pin(async_stream::stream! {
|
Box::pin(async_stream::stream! {
|
||||||
let mut rows = sqlx::query_as::<_, DiaryRow>(
|
let mut rows = sqlx::query_as::<_, DiaryRow>(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = ?
|
WHERE r.user_id = ?
|
||||||
@@ -475,7 +475,7 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
let rows = sqlx::query_as::<_, FeedRow>(
|
let rows = sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
|
||||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
|
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
|
||||||
WHEN u.email IS NOT NULL THEN u.email
|
WHEN u.email IS NOT NULL THEN u.email
|
||||||
ELSE r.user_id END AS user_email
|
ELSE r.user_id END AS user_email
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ pub(crate) struct ReviewRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReviewRow {
|
impl ReviewRow {
|
||||||
@@ -109,6 +110,7 @@ impl ReviewRow {
|
|||||||
None => ReviewSource::Local,
|
None => ReviewSource::Local,
|
||||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||||
};
|
};
|
||||||
|
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||||
Ok(Review::from_persistence(PersistedReview {
|
Ok(Review::from_persistence(PersistedReview {
|
||||||
id,
|
id,
|
||||||
movie_id,
|
movie_id,
|
||||||
@@ -118,6 +120,7 @@ impl ReviewRow {
|
|||||||
watched_at,
|
watched_at,
|
||||||
created_at,
|
created_at,
|
||||||
source,
|
source,
|
||||||
|
watch_medium,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +142,7 @@ pub(crate) struct DiaryRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiaryRow {
|
impl DiaryRow {
|
||||||
@@ -162,6 +166,7 @@ impl DiaryRow {
|
|||||||
watched_at: self.watched_at,
|
watched_at: self.watched_at,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
remote_actor_url: self.remote_actor_url,
|
remote_actor_url: self.remote_actor_url,
|
||||||
|
watch_medium: self.watch_medium,
|
||||||
}
|
}
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
@@ -215,6 +220,7 @@ pub(crate) struct FeedRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
pub user_email: String,
|
pub user_email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +241,7 @@ impl FeedRow {
|
|||||||
watched_at: self.watched_at,
|
watched_at: self.watched_at,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
remote_actor_url: self.remote_actor_url,
|
remote_actor_url: self.remote_actor_url,
|
||||||
|
watch_medium: self.watch_medium,
|
||||||
}
|
}
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(FeedEntry::new(diary, self.user_email))
|
Ok(FeedEntry::new(diary, self.user_email))
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ impl ReviewRepository for SqliteReviewRepository {
|
|||||||
};
|
};
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
@@ -52,6 +52,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
|||||||
.bind(&watched_at)
|
.bind(&watched_at)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.bind(&remote_actor_url)
|
.bind(&remote_actor_url)
|
||||||
|
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
@@ -68,7 +69,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
|||||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||||
let id = review_id.value().to_string();
|
let id = review_id.value().to_string();
|
||||||
sqlx::query_as::<_, ReviewRow>(
|
sqlx::query_as::<_, ReviewRow>(
|
||||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||||
FROM reviews WHERE id = ?",
|
FROM reviews WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
@@ -79,6 +80,28 @@ impl ReviewRepository for SqliteReviewRepository {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||||
|
let id = review.id().value().to_string();
|
||||||
|
let rating = review.rating().value() as i64;
|
||||||
|
let comment = review.comment().map(|c| c.value().to_string());
|
||||||
|
let watched_at = datetime_to_str(review.watched_at());
|
||||||
|
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(rating)
|
||||||
|
.bind(&comment)
|
||||||
|
.bind(&watched_at)
|
||||||
|
.bind(&watch_medium)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||||
let id = review_id.value().to_string();
|
let id = review_id.value().to_string();
|
||||||
sqlx::query("DELETE FROM reviews WHERE id = ?")
|
sqlx::query("DELETE FROM reviews WHERE id = ?")
|
||||||
@@ -92,7 +115,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
|||||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||||
let uid = user_id.value().to_string();
|
let uid = user_id.value().to_string();
|
||||||
sqlx::query_as::<_, ReviewRow>(
|
sqlx::query_as::<_, ReviewRow>(
|
||||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||||
FROM reviews WHERE user_id = ? ORDER BY watched_at DESC",
|
FROM reviews WHERE user_id = ? ORDER BY watched_at DESC",
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if let Some(wm) = entry.review().watch_medium() %}
|
||||||
|
<span class="watch-medium">{{ wm }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if let Some(comment) = entry.review().comment() %}
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
<div class="comment">{{ comment.value() }}</div>
|
<div class="comment">{{ comment.value() }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -35,6 +35,19 @@
|
|||||||
Comment<br>
|
Comment<br>
|
||||||
<textarea name="comment"></textarea>
|
<textarea name="comment"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Watched via <span class="optional">(optional)</span><br>
|
||||||
|
<select name="watch_medium">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="cinema">Cinema</option>
|
||||||
|
<option value="streaming">Streaming</option>
|
||||||
|
<option value="tv">TV</option>
|
||||||
|
<option value="physical_media">Physical Media</option>
|
||||||
|
<option value="download">Download</option>
|
||||||
|
<option value="media_server">Media Server</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||||
<button type="submit">Log Review</button>
|
<button type="submit">Log Review</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ pub struct LogReviewRequest {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
@@ -77,6 +79,18 @@ pub struct ExportQueryParams {
|
|||||||
pub format: String,
|
pub format: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct EditReviewRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rating: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub comment: Option<Option<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub watched_at: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub watch_medium: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_export_format() -> String {
|
fn default_export_format() -> String {
|
||||||
"csv".to_string()
|
"csv".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ pub struct ReviewDto {
|
|||||||
pub rating: u8,
|
pub rating: u8,
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
@@ -122,6 +124,8 @@ pub struct SocialReviewDto {
|
|||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub is_federated: bool,
|
pub is_federated: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
use domain::value_objects::WatchMedium;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct MovieInput {
|
pub struct MovieInput {
|
||||||
@@ -15,6 +16,7 @@ pub struct LogReviewCommand {
|
|||||||
pub rating: u8,
|
pub rating: u8,
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub watched_at: NaiveDateTime,
|
pub watched_at: NaiveDateTime,
|
||||||
|
pub watch_medium: Option<WatchMedium>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DeleteReviewCommand {
|
pub struct DeleteReviewCommand {
|
||||||
@@ -22,6 +24,15 @@ pub struct DeleteReviewCommand {
|
|||||||
pub requesting_user_id: Uuid,
|
pub requesting_user_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct EditReviewCommand {
|
||||||
|
pub review_id: Uuid,
|
||||||
|
pub requesting_user_id: Uuid,
|
||||||
|
pub rating: Option<u8>,
|
||||||
|
pub comment: Option<Option<String>>,
|
||||||
|
pub watched_at: Option<NaiveDateTime>,
|
||||||
|
pub watch_medium: Option<Option<WatchMedium>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SyncPosterCommand {
|
pub struct SyncPosterCommand {
|
||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ pub struct DeleteReviewDeps {
|
|||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct EditReviewDeps {
|
||||||
|
pub review: Arc<dyn ReviewRepository>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GetMovieSocialPageDeps {
|
pub struct GetMovieSocialPageDeps {
|
||||||
pub movie: Arc<dyn MovieRepository>,
|
pub movie: Arc<dyn MovieRepository>,
|
||||||
pub diary: Arc<dyn DiaryRepository>,
|
pub diary: Arc<dyn DiaryRepository>,
|
||||||
|
|||||||
60
crates/application/src/diary/edit_review.rs
Normal file
60
crates/application/src/diary/edit_review.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::ReviewEdit,
|
||||||
|
value_objects::{Comment, Rating, ReviewId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<(), DomainError> {
|
||||||
|
let review_id = ReviewId::from_uuid(cmd.review_id);
|
||||||
|
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
|
||||||
|
|
||||||
|
let review = deps
|
||||||
|
.review
|
||||||
|
.get_review_by_id(&review_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?;
|
||||||
|
|
||||||
|
if review.is_remote() {
|
||||||
|
return Err(DomainError::Forbidden(
|
||||||
|
"cannot edit a federated review".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if review.user_id() != &requesting_user_id {
|
||||||
|
return Err(DomainError::Forbidden("not your review".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = review.apply_edit(ReviewEdit {
|
||||||
|
rating: cmd.rating.map(Rating::new).transpose()?,
|
||||||
|
comment: cmd
|
||||||
|
.comment
|
||||||
|
.map(|c| c.map(Comment::new).transpose())
|
||||||
|
.transpose()?,
|
||||||
|
watched_at: cmd.watched_at,
|
||||||
|
watch_medium: cmd.watch_medium,
|
||||||
|
});
|
||||||
|
|
||||||
|
deps.review.update_review(&updated).await?;
|
||||||
|
|
||||||
|
if let Err(e) = deps
|
||||||
|
.event_publisher
|
||||||
|
.publish(&DomainEvent::ReviewUpdated {
|
||||||
|
review_id: updated.id().clone(),
|
||||||
|
movie_id: updated.movie_id().clone(),
|
||||||
|
user_id: updated.user_id().clone(),
|
||||||
|
rating: updated.rating().clone(),
|
||||||
|
watched_at: *updated.watched_at(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("failed to publish ReviewUpdated: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/edit_review.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod delete_review;
|
pub mod delete_review;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
|
pub mod edit_review;
|
||||||
pub mod export_diary;
|
pub mod export_diary;
|
||||||
pub mod get_activity_feed;
|
pub mod get_activity_feed;
|
||||||
pub mod get_diary;
|
pub mod get_diary;
|
||||||
|
|||||||
@@ -68,7 +68,14 @@ impl ReviewLogger for DefaultReviewLogger {
|
|||||||
|
|
||||||
self.movie_repo.upsert_movie(&movie).await?;
|
self.movie_repo.upsert_movie(&movie).await?;
|
||||||
|
|
||||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
let review = Review::new(
|
||||||
|
movie.id().clone(),
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
cmd.watched_at,
|
||||||
|
cmd.watch_medium,
|
||||||
|
)?;
|
||||||
let review_event = self.review_repo.save_review(&review).await?;
|
let review_event = self.review_repo.save_review(&review).await?;
|
||||||
|
|
||||||
let was_on_watchlist = self
|
let was_on_watchlist = self
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
|||||||
Rating::new(4).unwrap(),
|
Rating::new(4).unwrap(),
|
||||||
None,
|
None,
|
||||||
Utc::now().naive_utc(),
|
Utc::now().naive_utc(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
165
crates/application/src/diary/tests/edit_review.rs
Normal file
165
crates/application/src/diary/tests/edit_review.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
models::Review,
|
||||||
|
ports::ReviewRepository,
|
||||||
|
testing::{InMemoryReviewRepository, NoopEventPublisher},
|
||||||
|
value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
|
||||||
|
|
||||||
|
fn make_review(user_id: UserId) -> Review {
|
||||||
|
Review::new(
|
||||||
|
MovieId::generate(),
|
||||||
|
user_id,
|
||||||
|
Rating::new(3).unwrap(),
|
||||||
|
Some(Comment::new("original comment".into()).unwrap()),
|
||||||
|
Utc::now().naive_utc(),
|
||||||
|
Some(WatchMedium::Streaming),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup() -> (
|
||||||
|
Arc<InMemoryReviewRepository>,
|
||||||
|
Arc<NoopEventPublisher>,
|
||||||
|
ReviewId,
|
||||||
|
UserId,
|
||||||
|
) {
|
||||||
|
let reviews = InMemoryReviewRepository::new();
|
||||||
|
let events = NoopEventPublisher::new();
|
||||||
|
let user_id = UserId::generate();
|
||||||
|
let review = make_review(user_id.clone());
|
||||||
|
let review_id = review.id().clone();
|
||||||
|
reviews.save_review(&review).await.unwrap();
|
||||||
|
(reviews, events, review_id, user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deps(
|
||||||
|
reviews: &Arc<InMemoryReviewRepository>,
|
||||||
|
events: &Arc<NoopEventPublisher>,
|
||||||
|
) -> EditReviewDeps {
|
||||||
|
EditReviewDeps {
|
||||||
|
review: Arc::clone(reviews) as _,
|
||||||
|
event_publisher: Arc::clone(events) as _,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn edit_own_review_updates_rating() {
|
||||||
|
let (reviews, events, review_id, user_id) = setup().await;
|
||||||
|
|
||||||
|
edit_review::execute(
|
||||||
|
&deps(&reviews, &events),
|
||||||
|
EditReviewCommand {
|
||||||
|
review_id: review_id.value(),
|
||||||
|
requesting_user_id: user_id.value(),
|
||||||
|
rating: Some(5),
|
||||||
|
comment: None,
|
||||||
|
watched_at: None,
|
||||||
|
watch_medium: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(updated.rating().value(), 5);
|
||||||
|
assert_eq!(updated.comment().unwrap().value(), "original comment");
|
||||||
|
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Streaming));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn edit_nonexistent_review_returns_not_found() {
|
||||||
|
let reviews = InMemoryReviewRepository::new();
|
||||||
|
let events = NoopEventPublisher::new();
|
||||||
|
|
||||||
|
let err = edit_review::execute(
|
||||||
|
&deps(&reviews, &events),
|
||||||
|
EditReviewCommand {
|
||||||
|
review_id: Uuid::new_v4(),
|
||||||
|
requesting_user_id: Uuid::new_v4(),
|
||||||
|
rating: Some(5),
|
||||||
|
comment: None,
|
||||||
|
watched_at: None,
|
||||||
|
watch_medium: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, domain::errors::DomainError::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn edit_other_users_review_returns_forbidden() {
|
||||||
|
let (reviews, events, review_id, _user_id) = setup().await;
|
||||||
|
|
||||||
|
let err = edit_review::execute(
|
||||||
|
&deps(&reviews, &events),
|
||||||
|
EditReviewCommand {
|
||||||
|
review_id: review_id.value(),
|
||||||
|
requesting_user_id: Uuid::new_v4(),
|
||||||
|
rating: Some(5),
|
||||||
|
comment: None,
|
||||||
|
watched_at: None,
|
||||||
|
watch_medium: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, domain::errors::DomainError::Forbidden(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn edit_publishes_review_updated_event() {
|
||||||
|
let (reviews, events, review_id, user_id) = setup().await;
|
||||||
|
|
||||||
|
edit_review::execute(
|
||||||
|
&deps(&reviews, &events),
|
||||||
|
EditReviewCommand {
|
||||||
|
review_id: review_id.value(),
|
||||||
|
requesting_user_id: user_id.value(),
|
||||||
|
rating: Some(1),
|
||||||
|
comment: None,
|
||||||
|
watched_at: None,
|
||||||
|
watch_medium: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let published = events.published();
|
||||||
|
assert_eq!(published.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
published[0],
|
||||||
|
domain::events::DomainEvent::ReviewUpdated { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn edit_sets_watch_medium() {
|
||||||
|
let (reviews, events, review_id, user_id) = setup().await;
|
||||||
|
|
||||||
|
edit_review::execute(
|
||||||
|
&deps(&reviews, &events),
|
||||||
|
EditReviewCommand {
|
||||||
|
review_id: review_id.value(),
|
||||||
|
requesting_user_id: user_id.value(),
|
||||||
|
rating: None,
|
||||||
|
comment: None,
|
||||||
|
watched_at: None,
|
||||||
|
watch_medium: Some(Some(WatchMedium::Cinema)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Cinema));
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ async fn test_log_review_creates_movie_and_review() {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
log_review::execute(&logger, cmd).await.unwrap();
|
log_review::execute(&logger, cmd).await.unwrap();
|
||||||
@@ -97,6 +98,7 @@ async fn test_log_review_reuses_existing_movie() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
log_review::execute(&logger, cmd).await.unwrap();
|
log_review::execute(&logger, cmd).await.unwrap();
|
||||||
@@ -118,6 +120,7 @@ async fn test_log_review_with_invalid_rating_fails() {
|
|||||||
rating: 6,
|
rating: 6,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
let result = log_review::execute(&logger, cmd).await;
|
let result = log_review::execute(&logger, cmd).await;
|
||||||
assert!(result.is_err(), "rating > 5 should fail");
|
assert!(result.is_err(), "rating > 5 should fail");
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ async fn logs_review_with_manual_movie() {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
@@ -103,6 +104,7 @@ async fn removes_from_watchlist_on_review() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
@@ -140,6 +142,7 @@ async fn logs_review_with_existing_movie_by_id() {
|
|||||||
rating: 3,
|
rating: 3,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
@@ -168,6 +171,7 @@ async fn existing_movie_not_found_returns_error() {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(logger.log_review(cmd).await.is_err());
|
assert!(logger.log_review(cmd).await.is_err());
|
||||||
@@ -193,6 +197,7 @@ async fn invalid_rating_returns_error() {
|
|||||||
rating: 6,
|
rating: 6,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = logger.log_review(cmd).await;
|
let result = logger.log_review(cmd).await;
|
||||||
@@ -222,6 +227,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
@@ -289,6 +295,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: Utc::now().naive_utc(),
|
watched_at: Utc::now().naive_utc(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
|
|||||||
@@ -118,5 +118,6 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
|
|||||||
rating,
|
rating,
|
||||||
comment: row.comment.clone(),
|
comment: row.comment.clone(),
|
||||||
watched_at,
|
watched_at,
|
||||||
|
watch_medium: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub async fn execute(
|
|||||||
rating: c.rating,
|
rating: c.rating,
|
||||||
comment: c.comment,
|
comment: c.comment,
|
||||||
watched_at: *event.watched_at(),
|
watched_at: *event.watched_at(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
review_logger.log_review(review_cmd).await?;
|
review_logger.log_review(review_cmd).await?;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{NaiveDateTime, Utc};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
value_objects::{Comment, MovieId, Rating, ReviewId, UserId, WatchMedium},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::movie::Movie;
|
use super::movie::Movie;
|
||||||
@@ -25,6 +25,16 @@ pub struct PersistedReview {
|
|||||||
pub watched_at: NaiveDateTime,
|
pub watched_at: NaiveDateTime,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub source: ReviewSource,
|
pub source: ReviewSource,
|
||||||
|
pub watch_medium: Option<WatchMedium>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partial update for a review. `None` = unchanged, `Some(None)` = clear, `Some(Some(v))` = set.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ReviewEdit {
|
||||||
|
pub rating: Option<Rating>,
|
||||||
|
pub comment: Option<Option<Comment>>,
|
||||||
|
pub watched_at: Option<NaiveDateTime>,
|
||||||
|
pub watch_medium: Option<Option<WatchMedium>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -37,6 +47,7 @@ pub struct Review {
|
|||||||
watched_at: NaiveDateTime,
|
watched_at: NaiveDateTime,
|
||||||
created_at: NaiveDateTime,
|
created_at: NaiveDateTime,
|
||||||
source: ReviewSource,
|
source: ReviewSource,
|
||||||
|
watch_medium: Option<WatchMedium>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Review {
|
impl Review {
|
||||||
@@ -46,6 +57,7 @@ impl Review {
|
|||||||
rating: Rating,
|
rating: Rating,
|
||||||
comment: Option<Comment>,
|
comment: Option<Comment>,
|
||||||
watched_at: NaiveDateTime,
|
watched_at: NaiveDateTime,
|
||||||
|
watch_medium: Option<WatchMedium>,
|
||||||
) -> Result<Self, DomainError> {
|
) -> Result<Self, DomainError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: ReviewId::generate(),
|
id: ReviewId::generate(),
|
||||||
@@ -56,6 +68,7 @@ impl Review {
|
|||||||
watched_at,
|
watched_at,
|
||||||
created_at: Utc::now().naive_utc(),
|
created_at: Utc::now().naive_utc(),
|
||||||
source: ReviewSource::Local,
|
source: ReviewSource::Local,
|
||||||
|
watch_medium,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +82,27 @@ impl Review {
|
|||||||
watched_at: row.watched_at,
|
watched_at: row.watched_at,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
|
watch_medium: row.watch_medium,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_edit(&self, edit: ReviewEdit) -> Self {
|
||||||
|
Self {
|
||||||
|
id: self.id.clone(),
|
||||||
|
movie_id: self.movie_id.clone(),
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
|
rating: edit.rating.unwrap_or_else(|| self.rating.clone()),
|
||||||
|
comment: match edit.comment {
|
||||||
|
Some(c) => c,
|
||||||
|
None => self.comment.clone(),
|
||||||
|
},
|
||||||
|
watched_at: edit.watched_at.unwrap_or(self.watched_at),
|
||||||
|
created_at: self.created_at,
|
||||||
|
source: self.source.clone(),
|
||||||
|
watch_medium: match edit.watch_medium {
|
||||||
|
Some(wm) => wm,
|
||||||
|
None => self.watch_medium,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +130,9 @@ impl Review {
|
|||||||
pub fn source(&self) -> &ReviewSource {
|
pub fn source(&self) -> &ReviewSource {
|
||||||
&self.source
|
&self.source
|
||||||
}
|
}
|
||||||
|
pub fn watch_medium(&self) -> Option<&WatchMedium> {
|
||||||
|
self.watch_medium.as_ref()
|
||||||
|
}
|
||||||
/// Returns [star1_filled, star2_filled, ..., star5_filled]
|
/// Returns [star1_filled, star2_filled, ..., star5_filled]
|
||||||
pub fn stars(&self) -> [bool; 5] {
|
pub fn stars(&self) -> [bool; 5] {
|
||||||
let r = self.rating.value();
|
let r = self.rating.value();
|
||||||
@@ -157,3 +194,7 @@ impl ReviewHistory {
|
|||||||
self.viewings.sort_by_key(|r| *r.watched_at());
|
self.viewings.sort_by_key(|r| *r.watched_at());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/review.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fn make_review() -> Review {
|
|||||||
Rating::new(4).unwrap(),
|
Rating::new(4).unwrap(),
|
||||||
Some(Comment::new("great".into()).unwrap()),
|
Some(Comment::new("great".into()).unwrap()),
|
||||||
chrono::Utc::now().naive_utc(),
|
chrono::Utc::now().naive_utc(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -112,6 +113,7 @@ fn review_from_persistence() {
|
|||||||
source: ReviewSource::Remote {
|
source: ReviewSource::Remote {
|
||||||
actor_url: "https://example.com/actor".into(),
|
actor_url: "https://example.com/actor".into(),
|
||||||
},
|
},
|
||||||
|
watch_medium: None,
|
||||||
});
|
});
|
||||||
assert_eq!(*r.id(), id);
|
assert_eq!(*r.id(), id);
|
||||||
assert!(r.is_remote());
|
assert!(r.is_remote());
|
||||||
|
|||||||
81
crates/domain/src/models/tests/review.rs
Normal file
81
crates/domain/src/models/tests/review.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_review(watch_medium: Option<WatchMedium>) -> Review {
|
||||||
|
Review::new(
|
||||||
|
MovieId::generate(),
|
||||||
|
UserId::generate(),
|
||||||
|
Rating::new(4).unwrap(),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_review_stores_watch_medium() {
|
||||||
|
let review = make_review(Some(WatchMedium::Cinema));
|
||||||
|
assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_review_without_medium() {
|
||||||
|
let review = make_review(None);
|
||||||
|
assert_eq!(review.watch_medium(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_edit_updates_rating_only() {
|
||||||
|
let original = make_review(Some(WatchMedium::Streaming));
|
||||||
|
let edited = original.apply_edit(ReviewEdit {
|
||||||
|
rating: Some(Rating::new(2).unwrap()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(edited.rating().value(), 2);
|
||||||
|
assert_eq!(edited.comment().unwrap().value(), "great film");
|
||||||
|
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Streaming));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_edit_clears_comment() {
|
||||||
|
let original = make_review(None);
|
||||||
|
let edited = original.apply_edit(ReviewEdit {
|
||||||
|
comment: Some(None),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(edited.comment().is_none());
|
||||||
|
assert_eq!(edited.rating().value(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_edit_sets_watch_medium() {
|
||||||
|
let original = make_review(None);
|
||||||
|
let edited = original.apply_edit(ReviewEdit {
|
||||||
|
watch_medium: Some(Some(WatchMedium::Cinema)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Cinema));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_edit_clears_watch_medium() {
|
||||||
|
let original = make_review(Some(WatchMedium::Cinema));
|
||||||
|
let edited = original.apply_edit(ReviewEdit {
|
||||||
|
watch_medium: Some(None),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(edited.watch_medium(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_edit_no_changes_preserves_all() {
|
||||||
|
let original = make_review(Some(WatchMedium::TV));
|
||||||
|
let edited = original.apply_edit(ReviewEdit::default());
|
||||||
|
assert_eq!(edited.rating().value(), 4);
|
||||||
|
assert_eq!(edited.comment().unwrap().value(), "great film");
|
||||||
|
assert_eq!(edited.watch_medium(), Some(&WatchMedium::TV));
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ pub trait DiaryRepository: Send + Sync {
|
|||||||
pub trait ReviewRepository: Send + Sync {
|
pub trait ReviewRepository: Send + Sync {
|
||||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||||
|
async fn update_review(&self, review: &Review) -> Result<(), DomainError>;
|
||||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
||||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
|
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime)
|
|||||||
Rating::new(rating).unwrap(),
|
Rating::new(rating).unwrap(),
|
||||||
None,
|
None,
|
||||||
watched_at,
|
watched_at,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,14 @@ impl ReviewRepository for InMemoryReviewRepository {
|
|||||||
Ok(self.store.lock().unwrap().get(&review_id.value()).cloned())
|
Ok(self.store.lock().unwrap().get(&review_id.value()).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(review.id().value(), review.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||||
self.store.lock().unwrap().remove(&review_id.value());
|
self.store.lock().unwrap().remove(&review_id.value());
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -158,3 +158,44 @@ fn password_value_preserves_content() {
|
|||||||
let raw = "supersecret!".to_string();
|
let raw = "supersecret!".to_string();
|
||||||
assert_eq!(Password::new(raw.clone()).unwrap().value(), raw);
|
assert_eq!(Password::new(raw.clone()).unwrap().value(), raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_medium_parses_valid_strings() {
|
||||||
|
let cases = [
|
||||||
|
("cinema", WatchMedium::Cinema),
|
||||||
|
("streaming", WatchMedium::Streaming),
|
||||||
|
("tv", WatchMedium::TV),
|
||||||
|
("physical_media", WatchMedium::PhysicalMedia),
|
||||||
|
("download", WatchMedium::Download),
|
||||||
|
("media_server", WatchMedium::MediaServer),
|
||||||
|
("other", WatchMedium::Other),
|
||||||
|
];
|
||||||
|
for (input, expected) in cases {
|
||||||
|
let parsed: WatchMedium = input.parse().unwrap();
|
||||||
|
assert_eq!(parsed, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_medium_rejects_invalid() {
|
||||||
|
assert!("nonsense".parse::<WatchMedium>().is_err());
|
||||||
|
assert!("".parse::<WatchMedium>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_medium_display_round_trips() {
|
||||||
|
let variants = [
|
||||||
|
WatchMedium::Cinema,
|
||||||
|
WatchMedium::Streaming,
|
||||||
|
WatchMedium::TV,
|
||||||
|
WatchMedium::PhysicalMedia,
|
||||||
|
WatchMedium::Download,
|
||||||
|
WatchMedium::MediaServer,
|
||||||
|
WatchMedium::Other,
|
||||||
|
];
|
||||||
|
for v in variants {
|
||||||
|
let s = v.to_string();
|
||||||
|
let parsed: WatchMedium = s.parse().unwrap();
|
||||||
|
assert_eq!(parsed, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,71 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::errors::DomainError;
|
use crate::errors::DomainError;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum WatchMedium {
|
||||||
|
Cinema,
|
||||||
|
Streaming,
|
||||||
|
TV,
|
||||||
|
PhysicalMedia,
|
||||||
|
Download,
|
||||||
|
MediaServer,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WatchMedium {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Cinema => write!(f, "cinema"),
|
||||||
|
Self::Streaming => write!(f, "streaming"),
|
||||||
|
Self::TV => write!(f, "tv"),
|
||||||
|
Self::PhysicalMedia => write!(f, "physical_media"),
|
||||||
|
Self::Download => write!(f, "download"),
|
||||||
|
Self::MediaServer => write!(f, "media_server"),
|
||||||
|
Self::Other => write!(f, "other"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for WatchMedium {
|
||||||
|
type Err = DomainError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"cinema" => Ok(Self::Cinema),
|
||||||
|
"streaming" => Ok(Self::Streaming),
|
||||||
|
"tv" => Ok(Self::TV),
|
||||||
|
"physical_media" => Ok(Self::PhysicalMedia),
|
||||||
|
"download" => Ok(Self::Download),
|
||||||
|
"media_server" => Ok(Self::MediaServer),
|
||||||
|
"other" => Ok(Self::Other),
|
||||||
|
_ => Err(DomainError::ValidationError(format!(
|
||||||
|
"unknown watch medium: {s}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_watched_at(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
|
||||||
|
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
|
||||||
|
.or_else(|_| {
|
||||||
|
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid"))
|
||||||
|
})
|
||||||
|
.map_err(|_| {
|
||||||
|
DomainError::ValidationError(format!(
|
||||||
|
"invalid date '{s}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_watched_at(dt: &chrono::NaiveDateTime) -> String {
|
||||||
|
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Rating(u8);
|
pub struct Rating(u8);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub struct LogReviewForm {
|
|||||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
|
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
#[serde(rename = "_csrf", default)]
|
#[serde(rename = "_csrf", default)]
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
}
|
}
|
||||||
@@ -170,6 +172,7 @@ pub struct LogReviewData {
|
|||||||
pub rating: u8,
|
pub rating: u8,
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub watched_at: NaiveDateTime,
|
pub watched_at: NaiveDateTime,
|
||||||
|
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -182,18 +185,23 @@ impl TryFrom<LogReviewForm> for LogReviewData {
|
|||||||
type Error = ParseReviewError;
|
type Error = ParseReviewError;
|
||||||
|
|
||||||
fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> {
|
fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> {
|
||||||
let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S")
|
let watched_at =
|
||||||
.or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M"))
|
domain::value_objects::parse_watched_at(&form.watched_at).map_err(|_| {
|
||||||
.or_else(|_| {
|
ParseReviewError {
|
||||||
chrono::NaiveDate::parse_from_str(&form.watched_at, "%Y-%m-%d")
|
field: "watched_at",
|
||||||
.map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid"))
|
message: format!(
|
||||||
})
|
"invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]",
|
||||||
|
form.watched_at
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let watch_medium = form
|
||||||
|
.watch_medium
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
.map_err(|_| ParseReviewError {
|
.map_err(|_| ParseReviewError {
|
||||||
field: "watched_at",
|
field: "watch_medium",
|
||||||
message: format!(
|
message: "invalid watch medium".into(),
|
||||||
"invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]",
|
|
||||||
form.watched_at
|
|
||||||
),
|
|
||||||
})?;
|
})?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
||||||
@@ -203,6 +211,7 @@ impl TryFrom<LogReviewForm> for LogReviewData {
|
|||||||
rating: form.rating,
|
rating: form.rating,
|
||||||
comment: form.comment,
|
comment: form.comment,
|
||||||
watched_at,
|
watched_at,
|
||||||
|
watch_medium,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,12 +220,8 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
|||||||
type Error = DomainError;
|
type Error = DomainError;
|
||||||
|
|
||||||
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
|
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
|
||||||
let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S")
|
let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?;
|
||||||
.map_err(|_| {
|
let watch_medium = req.watch_medium.map(|s| s.parse()).transpose()?;
|
||||||
DomainError::ValidationError(
|
|
||||||
"invalid watched_at; expected YYYY-MM-DDTHH:MM:SS".into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
|
||||||
manual_title: req.manual_title,
|
manual_title: req.manual_title,
|
||||||
@@ -225,6 +230,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
|||||||
rating: req.rating,
|
rating: req.rating,
|
||||||
comment: req.comment,
|
comment: req.comment,
|
||||||
watched_at,
|
watched_at,
|
||||||
|
watch_medium,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,6 +249,7 @@ impl LogReviewData {
|
|||||||
rating: self.rating,
|
rating: self.rating,
|
||||||
comment: self.comment,
|
comment: self.comment,
|
||||||
watched_at: self.watched_at,
|
watched_at: self.watched_at,
|
||||||
|
watch_medium: self.watch_medium,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ use futures::StreamExt;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::diary::{
|
use application::diary::{
|
||||||
commands::DeleteReviewCommand,
|
commands::{DeleteReviewCommand, EditReviewCommand},
|
||||||
delete_review,
|
delete_review,
|
||||||
deps::{DeleteReviewDeps, GetActivityFeedDeps},
|
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
|
||||||
export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, log_review,
|
edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary,
|
||||||
|
log_review,
|
||||||
queries::{ExportQuery, GetActivityFeedQuery},
|
queries::{ExportQuery, GetActivityFeedQuery},
|
||||||
};
|
};
|
||||||
use domain::models::ExportFormat;
|
use domain::models::ExportFormat;
|
||||||
@@ -27,7 +28,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse,
|
ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse,
|
||||||
ExportQueryParams, LogReviewRequest,
|
EditReviewRequest, ExportQueryParams, LogReviewRequest,
|
||||||
};
|
};
|
||||||
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
|
use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items};
|
||||||
|
|
||||||
@@ -121,6 +122,55 @@ pub async fn delete_review(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/api/v1/reviews/{id}",
|
||||||
|
request_body = EditReviewRequest,
|
||||||
|
params(("id" = Uuid, Path, description = "Review ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Review updated"),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden"),
|
||||||
|
(status = 404, description = "Review not found"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn patch_review(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
Path(review_id): Path<Uuid>,
|
||||||
|
Json(req): Json<EditReviewRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let watched_at = req
|
||||||
|
.watched_at
|
||||||
|
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let watch_medium = req
|
||||||
|
.watch_medium
|
||||||
|
.map(|opt| {
|
||||||
|
opt.map(|s| s.parse::<domain::value_objects::WatchMedium>())
|
||||||
|
.transpose()
|
||||||
|
.map_err(ApiError)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let cmd = EditReviewCommand {
|
||||||
|
review_id,
|
||||||
|
requesting_user_id: user_id.value(),
|
||||||
|
rating: req.rating,
|
||||||
|
comment: req.comment,
|
||||||
|
watched_at,
|
||||||
|
watch_medium,
|
||||||
|
};
|
||||||
|
let deps = EditReviewDeps {
|
||||||
|
review: state.app_ctx.repos.review.clone(),
|
||||||
|
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||||
|
};
|
||||||
|
edit_review::execute(&deps, cmd).await?;
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/api/v1/diary/export",
|
get, path = "/api/v1/diary/export",
|
||||||
params(ExportQueryParams),
|
params(ExportQueryParams),
|
||||||
|
|||||||
@@ -183,8 +183,9 @@ pub async fn get_movie_detail(
|
|||||||
user_display: e.user_display_name().to_string(),
|
user_display: e.user_display_name().to_string(),
|
||||||
rating: e.review().rating().value(),
|
rating: e.review().rating().value(),
|
||||||
comment: e.review().comment().map(|c| c.value().to_string()),
|
comment: e.review().comment().map(|c| c.value().to_string()),
|
||||||
watched_at: e.review().watched_at().to_string(),
|
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
|
||||||
is_federated: e.review().is_remote(),
|
is_federated: e.review().is_remote(),
|
||||||
|
watch_medium: e.review().watch_medium().map(|wm| wm.to_string()),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
total_count: result.reviews.total_count,
|
total_count: result.reviews.total_count,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
|
|||||||
id: review.id().value(),
|
id: review.id().value(),
|
||||||
rating: review.rating().value(),
|
rating: review.rating().value(),
|
||||||
comment: review.comment().map(|c| c.value().to_string()),
|
comment: review.comment().map(|c| c.value().to_string()),
|
||||||
watched_at: review.watched_at().to_string(),
|
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
|
||||||
|
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ fn cors_layer() -> CorsLayer {
|
|||||||
Method::GET,
|
Method::GET,
|
||||||
Method::POST,
|
Method::POST,
|
||||||
Method::PUT,
|
Method::PUT,
|
||||||
|
Method::PATCH,
|
||||||
Method::DELETE,
|
Method::DELETE,
|
||||||
Method::OPTIONS,
|
Method::OPTIONS,
|
||||||
])
|
])
|
||||||
@@ -318,7 +319,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
.route("/reviews", routing::post(handlers::diary::post_review))
|
.route("/reviews", routing::post(handlers::diary::post_review))
|
||||||
.route(
|
.route(
|
||||||
"/reviews/{id}",
|
"/reviews/{id}",
|
||||||
routing::delete(handlers::diary::delete_review),
|
routing::delete(handlers::diary::delete_review).patch(handlers::diary::patch_review),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/movies/{id}/sync-poster",
|
"/movies/{id}/sync-poster",
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ impl ReviewRepository for Panic {
|
|||||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
|
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn update_review(&self, _: &Review) -> Result<(), DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
|
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fn make_form(watched_at: &str) -> LogReviewForm {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: watched_at.to_string(),
|
watched_at: watched_at.to_string(),
|
||||||
|
watch_medium: None,
|
||||||
csrf_token: String::new(),
|
csrf_token: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,7 @@ fn make_request(watched_at: &str) -> LogReviewRequest {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: watched_at.to_string(),
|
watched_at: watched_at.to_string(),
|
||||||
|
watch_medium: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +51,9 @@ fn api_accepts_datetime_with_seconds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn api_rejects_datetime_without_seconds() {
|
fn api_accepts_datetime_without_seconds() {
|
||||||
assert!(LogReviewData::try_from(make_request("2024-03-15T20:30")).is_err());
|
let data = LogReviewData::try_from(make_request("2024-03-15T20:30")).unwrap();
|
||||||
|
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
|
|||||||
Some(comment)
|
Some(comment)
|
||||||
},
|
},
|
||||||
watched_at,
|
watched_at,
|
||||||
|
watch_medium: None,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -856,6 +857,7 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
|||||||
rating,
|
rating,
|
||||||
comment,
|
comment,
|
||||||
watched_at,
|
watched_at,
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
app.loading = true;
|
app.loading = true;
|
||||||
return vec![Command::CreateReview(req)];
|
return vec![Command::CreateReview(req)];
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ fn diary_entry() -> DiaryEntryDto {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "1999-03-31T00:00:00".into(),
|
watched_at: "1999-03-31T00:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,6 +382,7 @@ fn bulk_import_all_with_valid_rows_returns_import_next_command() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "1999-03-31T00:00:00".into(),
|
watched_at: "1999-03-31T00:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
}),
|
}),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -403,6 +405,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "2024-01-01T00:00:00".into(),
|
watched_at: "2024-01-01T00:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
},
|
},
|
||||||
LogReviewRequest {
|
LogReviewRequest {
|
||||||
external_metadata_id: None,
|
external_metadata_id: None,
|
||||||
@@ -412,6 +415,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
|
|||||||
rating: 4,
|
rating: 4,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "2024-01-02T00:00:00".into(),
|
watched_at: "2024-01-02T00:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
m.bulk_import.results = vec![None, None];
|
m.bulk_import.results = vec![None, None];
|
||||||
@@ -440,6 +444,7 @@ fn bulk_item_done_last_item_transitions_to_done() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "2024-01-01T00:00:00".into(),
|
watched_at: "2024-01-01T00:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
}];
|
}];
|
||||||
m.bulk_import.results = vec![None];
|
m.bulk_import.results = vec![None];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fn log_review_request_skips_none_fields() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "2024-01-15T20:00:00".into(),
|
watched_at: "2024-01-15T20:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&req).unwrap();
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
assert!(!json.contains("external_metadata_id"));
|
assert!(!json.contains("external_metadata_id"));
|
||||||
@@ -42,6 +43,7 @@ fn log_review_request_includes_director_when_set() {
|
|||||||
rating: 5,
|
rating: 5,
|
||||||
comment: None,
|
comment: None,
|
||||||
watched_at: "2024-01-15T20:00:00".into(),
|
watched_at: "2024-01-15T20:00:00".into(),
|
||||||
|
watch_medium: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&req).unwrap();
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));
|
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));
|
||||||
|
|||||||
24
deploy.sh
24
deploy.sh
@@ -1,9 +1,25 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="registry.gabrielkaszewski.dev/movies-diary:latest"
|
REGISTRY="registry.gabrielkaszewski.dev"
|
||||||
|
REPO="movies-diary"
|
||||||
|
DEFAULT_FEATURES="sqlite,sqlite-federation,nats"
|
||||||
|
|
||||||
|
features="${FEATURES:-$DEFAULT_FEATURES}"
|
||||||
|
tag="latest"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--features) features="$2"; shift 2 ;;
|
||||||
|
--tag) tag="$2"; shift 2 ;;
|
||||||
|
*) echo "usage: $0 [--features F] [--tag T]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
image="${REGISTRY}/${REPO}:${tag}"
|
||||||
|
|
||||||
|
echo "building ${image} features=${features}"
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
--build-arg FEATURES=sqlite,sqlite-federation,nats \
|
--build-arg FEATURES="$features" \
|
||||||
-t "$IMAGE" --push .
|
-t "$image" --push .
|
||||||
echo "pushed $IMAGE"
|
echo "pushed $image"
|
||||||
|
|||||||
@@ -301,6 +301,28 @@ body > #root {
|
|||||||
fill: rgba(255, 255, 255, 0.85) !important;
|
fill: rgba(255, 255, 255, 0.85) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tooltip — default shadcn uses bg-foreground which is white in this theme */
|
||||||
|
[data-slot="tooltip-content"] {
|
||||||
|
background: var(--popover);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
backdrop-filter: blur(var(--aero-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--aero-blur));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="tooltip-content"] .lucide,
|
||||||
|
[data-slot="tooltip-content"] [data-slot="tooltip-arrow"] {
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
[data-slot="context-menu-content"] {
|
||||||
|
background: var(--popover);
|
||||||
|
backdrop-filter: blur(var(--aero-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--aero-blur));
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
/* Star glow for filled amber stars */
|
/* Star glow for filled amber stars */
|
||||||
.aero-star-filled {
|
.aero-star-filled {
|
||||||
filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary));
|
filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary));
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export function BackButton() {
|
export function BackButton() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => router.history.back()}
|
onClick={() => router.history.back()}
|
||||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
|
className="gap-1 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" /> {t("common.back")}
|
<ArrowLeft className="size-4" /> {t("common.back")}
|
||||||
</button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
112
spa/src/components/edit-review-sheet.tsx
Normal file
112
spa/src/components/edit-review-sheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { VisuallyHidden } from "radix-ui"
|
||||||
|
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ReviewFormFields } from "@/components/review-form-fields"
|
||||||
|
import { useEditReview } from "@/hooks/use-diary"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { posterUrl } from "@/lib/api/client"
|
||||||
|
import { hapticMedium } from "@/lib/haptics"
|
||||||
|
import type { EditReviewRequest } from "@/lib/api/diary"
|
||||||
|
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
||||||
|
|
||||||
|
type EditReviewSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
movie: MovieDto
|
||||||
|
review: ReviewDto
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDate(s: string): Date {
|
||||||
|
const [datePart, timePart] = s.split("T")
|
||||||
|
if (!datePart) return new Date()
|
||||||
|
const [y, m, d] = datePart.split("-").map(Number)
|
||||||
|
if (timePart) {
|
||||||
|
const [h, min, sec] = timePart.split(":").map(Number)
|
||||||
|
return new Date(y!, m! - 1, d!, h, min, sec)
|
||||||
|
}
|
||||||
|
return new Date(y!, m! - 1, d!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalDateTime(d: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0")
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditReviewSheet({ open, onOpenChange, movie, review }: EditReviewSheetProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [rating, setRating] = useState(review.rating)
|
||||||
|
const [comment, setComment] = useState(review.comment ?? "")
|
||||||
|
const [watchedAt, setWatchedAt] = useState<Date>(() => parseLocalDate(review.watched_at))
|
||||||
|
const [dateChanged, setDateChanged] = useState(false)
|
||||||
|
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
|
||||||
|
const editMutation = useEditReview()
|
||||||
|
|
||||||
|
function handleDateChange(d: Date) {
|
||||||
|
setWatchedAt(d)
|
||||||
|
setDateChanged(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!rating) return
|
||||||
|
|
||||||
|
const data: Partial<EditReviewRequest> = {}
|
||||||
|
if (rating !== review.rating) data.rating = rating
|
||||||
|
const newComment = comment || null
|
||||||
|
if (newComment !== (review.comment ?? null)) data.comment = newComment
|
||||||
|
if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
|
||||||
|
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
toast.info(t("editReview.noChanges"))
|
||||||
|
onOpenChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editMutation.mutate(
|
||||||
|
{ id: review.id, data },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
hapticMedium()
|
||||||
|
toast.success(t("editReview.saved", { title: movie.title }))
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent className="mx-auto max-w-lg">
|
||||||
|
<VisuallyHidden.Root><DrawerTitle>{t("editReview.title")}</DrawerTitle></VisuallyHidden.Root>
|
||||||
|
<div className="p-5 pb-8">
|
||||||
|
<div className="mb-5 flex gap-3">
|
||||||
|
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||||
|
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold">{movie.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReviewFormFields
|
||||||
|
rating={rating}
|
||||||
|
onRatingChange={setRating}
|
||||||
|
comment={comment}
|
||||||
|
onCommentChange={setComment}
|
||||||
|
watchedAt={watchedAt}
|
||||||
|
onWatchedAtChange={handleDateChange}
|
||||||
|
watchMedium={watchMedium}
|
||||||
|
onWatchMediumChange={setWatchMedium}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
|
||||||
|
{editMutation.isPending ? t("editReview.saving") : t("editReview.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
spa/src/components/editable-context-menu.tsx
Normal file
31
spa/src/components/editable-context-menu.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Pencil } from "lucide-react"
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu"
|
||||||
|
|
||||||
|
type EditableContextMenuProps = {
|
||||||
|
onEdit: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableContextMenu({ onEdit, children }: EditableContextMenuProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div>{children}</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={onEdit}>
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
{t("editReview.title")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { VisuallyHidden } from "radix-ui"
|
import { VisuallyHidden } from "radix-ui"
|
||||||
import { CalendarIcon } from "lucide-react"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { ReviewFormFields } from "@/components/review-form-fields"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import { StarRating } from "@/components/star-rating"
|
|
||||||
import { SearchOverlay } from "@/components/search-overlay"
|
import { SearchOverlay } from "@/components/search-overlay"
|
||||||
import type { MovieSelection } from "@/components/search-overlay"
|
import type { MovieSelection } from "@/components/search-overlay"
|
||||||
import { useLogReview } from "@/hooks/use-diary"
|
import { useLogReview } from "@/hooks/use-diary"
|
||||||
@@ -27,6 +22,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
|
|||||||
const [rating, setRating] = useState(0)
|
const [rating, setRating] = useState(0)
|
||||||
const [comment, setComment] = useState("")
|
const [comment, setComment] = useState("")
|
||||||
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
|
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
|
||||||
|
const [watchMedium, setWatchMedium] = useState<string | undefined>()
|
||||||
const logMutation = useLogReview()
|
const logMutation = useLogReview()
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -34,6 +30,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
|
|||||||
setRating(0)
|
setRating(0)
|
||||||
setComment("")
|
setComment("")
|
||||||
setWatchedAt(new Date())
|
setWatchedAt(new Date())
|
||||||
|
setWatchMedium(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -52,6 +49,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
|
|||||||
rating,
|
rating,
|
||||||
comment: comment || undefined,
|
comment: comment || undefined,
|
||||||
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
|
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
|
||||||
|
watch_medium: watchMedium,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -85,34 +83,16 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-5 text-center">
|
<ReviewFormFields
|
||||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.yourRating")}</p>
|
rating={rating}
|
||||||
<div className="flex justify-center"><StarRating value={rating} onChange={setRating} /></div>
|
onRatingChange={setRating}
|
||||||
</div>
|
comment={comment}
|
||||||
|
onCommentChange={setComment}
|
||||||
<Textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
|
watchedAt={watchedAt}
|
||||||
|
onWatchedAtChange={setWatchedAt}
|
||||||
<div className="mb-5">
|
watchMedium={watchMedium}
|
||||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
onWatchMediumChange={setWatchMedium}
|
||||||
<Popover modal>
|
/>
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
|
||||||
<CalendarIcon className="mr-2 size-4" />
|
|
||||||
{format(watchedAt, "PPP")}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
fixedWeeks
|
|
||||||
selected={watchedAt}
|
|
||||||
onSelect={(d) => d && setWatchedAt(d)}
|
|
||||||
disabled={(d) => d > new Date()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
|
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
|
||||||
{logMutation.isPending ? t("logReview.logging") : t("logReview.logReview")}
|
{logMutation.isPending ? t("logReview.logging") : t("logReview.logReview")}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { Globe } from "lucide-react"
|
import { Globe, Pencil } from "lucide-react"
|
||||||
import { timeAgo } from "@/lib/date"
|
import { timeAgo } from "@/lib/date"
|
||||||
import { StarDisplay } from "@/components/star-display"
|
import { StarDisplay } from "@/components/star-display"
|
||||||
|
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||||
|
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { posterUrl } from "@/lib/api/client"
|
import { posterUrl } from "@/lib/api/client"
|
||||||
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
||||||
@@ -13,10 +16,11 @@ type ReviewCardProps = {
|
|||||||
userId?: string
|
userId?: string
|
||||||
isFederated?: boolean
|
isFederated?: boolean
|
||||||
actorUrl?: string
|
actorUrl?: string
|
||||||
|
onEdit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl }: ReviewCardProps) {
|
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit }: ReviewCardProps) {
|
||||||
return (
|
const card = (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
<CardContent className="flex gap-3">
|
<CardContent className="flex gap-3">
|
||||||
<Link to="/movies/$id" params={{ id: movie.id }} className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
|
<Link to="/movies/$id" params={{ id: movie.id }} className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||||
@@ -41,13 +45,27 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
|||||||
<span>{timeAgo(review.watched_at)}</span>
|
<span>{timeAgo(review.watched_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link to="/movies/$id" params={{ id: movie.id }} className="font-semibold hover:underline">
|
<div className="flex items-center justify-between">
|
||||||
{movie.title}
|
<Link to="/movies/$id" params={{ id: movie.id }} className="font-semibold hover:underline">
|
||||||
</Link>
|
{movie.title}
|
||||||
<StarDisplay rating={review.rating} />
|
</Link>
|
||||||
|
{onEdit && (
|
||||||
|
<Button variant="ghost" size="icon" className="hidden size-7 md:inline-flex" onClick={onEdit}>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StarDisplay rating={review.rating} />
|
||||||
|
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||||
|
</div>
|
||||||
{review.comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{review.comment}</p>}
|
{review.comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{review.comment}</p>}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!onEdit) return card
|
||||||
|
|
||||||
|
return <EditableContextMenu onEdit={onEdit}>{card}</EditableContextMenu>
|
||||||
}
|
}
|
||||||
|
|||||||
70
spa/src/components/review-form-fields.tsx
Normal file
70
spa/src/components/review-form-fields.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { CalendarIcon } from "lucide-react"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { StarRating } from "@/components/star-rating"
|
||||||
|
import { WatchMediumPicker } from "@/components/watch-medium-picker"
|
||||||
|
|
||||||
|
type ReviewFormFieldsProps = {
|
||||||
|
rating: number
|
||||||
|
onRatingChange: (v: number) => void
|
||||||
|
comment: string
|
||||||
|
onCommentChange: (v: string) => void
|
||||||
|
watchedAt: Date
|
||||||
|
onWatchedAtChange: (v: Date) => void
|
||||||
|
watchMedium?: string
|
||||||
|
onWatchMediumChange: (v: string | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewFormFields({
|
||||||
|
rating,
|
||||||
|
onRatingChange,
|
||||||
|
comment,
|
||||||
|
onCommentChange,
|
||||||
|
watchedAt,
|
||||||
|
onWatchedAtChange,
|
||||||
|
watchMedium,
|
||||||
|
onWatchMediumChange,
|
||||||
|
}: ReviewFormFieldsProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 text-center">
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.yourRating")}</p>
|
||||||
|
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea value={comment} onChange={(e) => onCommentChange(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
||||||
|
<Popover modal>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||||
|
<CalendarIcon className="mr-2 size-4" />
|
||||||
|
{format(watchedAt, "PPP")}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
fixedWeeks
|
||||||
|
selected={watchedAt}
|
||||||
|
onSelect={(d) => d && onWatchedAtChange(d)}
|
||||||
|
disabled={(d) => d > new Date()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<WatchMediumPicker value={watchMedium} onChange={onWatchMediumChange} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -113,9 +113,9 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
|||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("searchOverlay.searchPlaceholder")} className="pl-9" autoFocus />
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("searchOverlay.searchPlaceholder")} className="pl-9" autoFocus />
|
||||||
{query && (
|
{query && (
|
||||||
<button onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2">
|
<Button variant="ghost" size="icon" onClick={() => setQuery("")} className="absolute right-3 top-1/2 size-6 -translate-y-1/2">
|
||||||
<X className="size-4 text-muted-foreground" />
|
<X className="size-4 text-muted-foreground" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
|
<Button variant="ghost" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ function TooltipContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
36
spa/src/components/watch-medium-badge.tsx
Normal file
36
spa/src/components/watch-medium-badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||||
|
|
||||||
|
type WatchMediumBadgeProps = {
|
||||||
|
medium: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchMediumBadge({ medium, className }: WatchMediumBadgeProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const entry = WATCH_MEDIUMS.find((m) => m.value === medium)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
const Icon = entry.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className={cn("size-6", className)} aria-label={t(entry.labelKey)}>
|
||||||
|
<Icon className="size-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t(entry.labelKey)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
spa/src/components/watch-medium-picker.tsx
Normal file
55
spa/src/components/watch-medium-picker.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||||
|
|
||||||
|
type WatchMediumPickerProps = {
|
||||||
|
value?: string
|
||||||
|
onChange: (value: string | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("watchMedium.label")}
|
||||||
|
</p>
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => {
|
||||||
|
const selected = value === val
|
||||||
|
return (
|
||||||
|
<Tooltip key={val}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8",
|
||||||
|
selected && "border-[var(--aero-primary)] bg-[var(--aero-primary)] text-white shadow-[0_0_8px_var(--aero-primary-glow)]",
|
||||||
|
)}
|
||||||
|
aria-label={t(labelKey)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => onChange(selected ? undefined : val)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={4}>{t(labelKey)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
deleteReview,
|
deleteReview,
|
||||||
|
editReview,
|
||||||
getActivityFeed,
|
getActivityFeed,
|
||||||
getDiary,
|
getDiary,
|
||||||
logReview,
|
logReview,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ActivityFeedQueryParams,
|
ActivityFeedQueryParams,
|
||||||
DiaryQueryParams,
|
DiaryQueryParams,
|
||||||
|
EditReviewRequest,
|
||||||
LogReviewRequest,
|
LogReviewRequest,
|
||||||
} from "@/lib/api/diary"
|
} from "@/lib/api/diary"
|
||||||
|
|
||||||
@@ -79,6 +81,18 @@ export function useLogReview() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useEditReview() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: EditReviewRequest }) =>
|
||||||
|
editReview(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: diaryKeys.all })
|
||||||
|
qc.invalidateQueries({ queryKey: ["activity-feed"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteReview() {
|
export function useDeleteReview() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ export async function putForm<T = void>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patch<T = void>(
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
return request<T>(buildUrl(path), {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function del<T = void>(path: string): Promise<T> {
|
export async function del<T = void>(path: string): Promise<T> {
|
||||||
return request<T>(buildUrl(path), { method: "DELETE" })
|
return request<T>(buildUrl(path), { method: "DELETE" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const reviewDtoSchema = z.object({
|
|||||||
rating: z.number(),
|
rating: z.number(),
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
watched_at: z.string(),
|
watched_at: z.string(),
|
||||||
|
watch_medium: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type ReviewDto = z.infer<typeof reviewDtoSchema>
|
export type ReviewDto = z.infer<typeof reviewDtoSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { DiaryEntryDto, Paginated } from "./common"
|
import type { DiaryEntryDto, Paginated } from "./common"
|
||||||
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
|
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
|
||||||
import { del, get, post } from "./client"
|
import { del, get, patch, post } from "./client"
|
||||||
|
|
||||||
export const diaryQueryParamsSchema = z.object({
|
export const diaryQueryParamsSchema = z.object({
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
@@ -22,9 +22,18 @@ export const logReviewRequestSchema = z.object({
|
|||||||
rating: z.number(),
|
rating: z.number(),
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
watched_at: z.string(),
|
watched_at: z.string(),
|
||||||
|
watch_medium: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
|
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
|
||||||
|
|
||||||
|
export const editReviewRequestSchema = z.object({
|
||||||
|
rating: z.number().optional(),
|
||||||
|
comment: z.string().nullable().optional(),
|
||||||
|
watched_at: z.string().optional(),
|
||||||
|
watch_medium: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
export type EditReviewRequest = z.infer<typeof editReviewRequestSchema>
|
||||||
|
|
||||||
export const feedEntryDtoSchema = z.object({
|
export const feedEntryDtoSchema = z.object({
|
||||||
movie: movieDtoSchema,
|
movie: movieDtoSchema,
|
||||||
review: reviewDtoSchema,
|
review: reviewDtoSchema,
|
||||||
@@ -58,6 +67,10 @@ export function logReview(data: LogReviewRequest) {
|
|||||||
return post("/reviews", data)
|
return post("/reviews", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function editReview(id: string, data: EditReviewRequest) {
|
||||||
|
return patch(`/reviews/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteReview(id: string) {
|
export function deleteReview(id: string) {
|
||||||
return del(`/reviews/${id}`)
|
return del(`/reviews/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const socialReviewDtoSchema = z.object({
|
|||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
watched_at: z.string(),
|
watched_at: z.string(),
|
||||||
is_federated: z.boolean(),
|
is_federated: z.boolean(),
|
||||||
|
watch_medium: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type SocialReviewDto = z.infer<typeof socialReviewDtoSchema>
|
export type SocialReviewDto = z.infer<typeof socialReviewDtoSchema>
|
||||||
|
|
||||||
|
|||||||
26
spa/src/lib/watch-mediums.ts
Normal file
26
spa/src/lib/watch-mediums.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Clapperboard,
|
||||||
|
Tv,
|
||||||
|
Radio,
|
||||||
|
Disc3,
|
||||||
|
Download,
|
||||||
|
Server,
|
||||||
|
Ellipsis,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export type WatchMediumDef = {
|
||||||
|
value: string
|
||||||
|
icon: LucideIcon
|
||||||
|
labelKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WATCH_MEDIUMS: WatchMediumDef[] = [
|
||||||
|
{ value: "cinema", icon: Clapperboard, labelKey: "watchMedium.cinema" },
|
||||||
|
{ value: "streaming", icon: Tv, labelKey: "watchMedium.streaming" },
|
||||||
|
{ value: "tv", icon: Radio, labelKey: "watchMedium.tv" },
|
||||||
|
{ value: "physical_media", icon: Disc3, labelKey: "watchMedium.physicalMedia" },
|
||||||
|
{ value: "download", icon: Download, labelKey: "watchMedium.download" },
|
||||||
|
{ value: "media_server", icon: Server, labelKey: "watchMedium.mediaServer" },
|
||||||
|
{ value: "other", icon: Ellipsis, labelKey: "watchMedium.other" },
|
||||||
|
]
|
||||||
@@ -307,6 +307,23 @@
|
|||||||
"funActors": "You saw {{count}} different actors",
|
"funActors": "You saw {{count}} different actors",
|
||||||
"allMovies": "All Movies ({{count}})"
|
"allMovies": "All Movies ({{count}})"
|
||||||
},
|
},
|
||||||
|
"watchMedium": {
|
||||||
|
"label": "Watched via",
|
||||||
|
"cinema": "Cinema",
|
||||||
|
"streaming": "Streaming",
|
||||||
|
"tv": "TV",
|
||||||
|
"physicalMedia": "Physical Media",
|
||||||
|
"download": "Download",
|
||||||
|
"mediaServer": "Media Server",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"editReview": {
|
||||||
|
"title": "Edit Review",
|
||||||
|
"save": "Save Changes",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saved": "{{title}} updated!",
|
||||||
|
"noChanges": "No changes to save"
|
||||||
|
},
|
||||||
"logReview": {
|
"logReview": {
|
||||||
"title": "Log Review",
|
"title": "Log Review",
|
||||||
"yourRating": "Your Rating",
|
"yourRating": "Your Rating",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@tanstack/react-router"
|
} from "@tanstack/react-router"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { BottomTabBar } from "@/components/bottom-tab-bar"
|
import { BottomTabBar } from "@/components/bottom-tab-bar"
|
||||||
import { LogSheet } from "@/components/log-sheet"
|
import { LogSheet } from "@/components/log-sheet"
|
||||||
@@ -26,12 +27,9 @@ function ErrorFallback({ error, reset }: { error: unknown; reset: () => void })
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{error instanceof Error ? error.message : t("errors.unknownError")}
|
{error instanceof Error ? error.message : t("errors.unknownError")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button onClick={reset}>
|
||||||
onClick={reset}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
||||||
>
|
|
||||||
{t("common.tryAgain")}
|
{t("common.tryAgain")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"
|
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
|
||||||
import { format, startOfMonth, subMonths } from "date-fns"
|
import { format, startOfMonth, subMonths } from "date-fns"
|
||||||
|
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||||
|
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||||
import { MovieCard } from "@/components/movie-card"
|
import { MovieCard } from "@/components/movie-card"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { SwipeToDelete } from "@/components/swipe-to-delete"
|
import { SwipeToDelete } from "@/components/swipe-to-delete"
|
||||||
|
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||||
import { VirtualList } from "@/components/virtual-list"
|
import { VirtualList } from "@/components/virtual-list"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -33,6 +36,7 @@ function DiaryPage() {
|
|||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: "desc" })
|
useInfiniteDiary({ sort_by: "desc" })
|
||||||
const deleteReview = useDeleteReview()
|
const deleteReview = useDeleteReview()
|
||||||
|
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||||
|
|
||||||
const monthLabel = format(month, "MMMM yyyy")
|
const monthLabel = format(month, "MMMM yyyy")
|
||||||
const monthStr = format(month, "yyyy-MM")
|
const monthStr = format(month, "yyyy-MM")
|
||||||
@@ -109,17 +113,44 @@ function DiaryPage() {
|
|||||||
confirmTitle={t("diary.deleteReview")}
|
confirmTitle={t("diary.deleteReview")}
|
||||||
confirmDescription={`${item.entry.movie.title} — ${item.entry.review.watched_at.slice(0, 10)}`}
|
confirmDescription={`${item.entry.movie.title} — ${item.entry.review.watched_at.slice(0, 10)}`}
|
||||||
>
|
>
|
||||||
<MovieCard
|
<EditableContextMenu onEdit={() => setEditingEntry(item.entry)}>
|
||||||
movie={item.entry.movie}
|
<MovieCard
|
||||||
rating={item.entry.review.rating}
|
movie={item.entry.movie}
|
||||||
comment={item.entry.review.comment}
|
rating={item.entry.review.rating}
|
||||||
variant="full"
|
comment={item.entry.review.comment}
|
||||||
/>
|
variant="full"
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{item.entry.review.watch_medium && (
|
||||||
|
<WatchMediumBadge medium={item.entry.review.watch_medium} />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hidden size-7 md:inline-flex"
|
||||||
|
onClick={(e) => { e.preventDefault(); setEditingEntry(item.entry) }}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EditableContextMenu>
|
||||||
</SwipeToDelete>
|
</SwipeToDelete>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingEntry && (
|
||||||
|
<EditReviewSheet
|
||||||
|
key={editingEntry.review.id}
|
||||||
|
open={!!editingEntry}
|
||||||
|
onOpenChange={(open) => !open && setEditingEntry(null)}
|
||||||
|
movie={editingEntry.movie}
|
||||||
|
review={editingEntry.review}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { StarRating } from "@/components/star-rating"
|
import { StarRating } from "@/components/star-rating"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||||
|
import type { FeedEntryDto } from "@/lib/api/diary"
|
||||||
import { SearchOverlay } from "@/components/search-overlay"
|
import { SearchOverlay } from "@/components/search-overlay"
|
||||||
import type { MovieSelection } from "@/components/search-overlay"
|
import type { MovieSelection } from "@/components/search-overlay"
|
||||||
import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/hooks/use-watchlist"
|
import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/hooks/use-watchlist"
|
||||||
@@ -66,6 +68,7 @@ function FeedTab() {
|
|||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteActivityFeed({ sort_by: sortBy })
|
useInfiniteActivityFeed({ sort_by: sortBy })
|
||||||
const deleteReview = useDeleteReview()
|
const deleteReview = useDeleteReview()
|
||||||
|
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
||||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ function FeedTab() {
|
|||||||
isFetching={isFetchingNextPage}
|
isFetching={isFetchingNextPage}
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
renderItem={(entry) => {
|
renderItem={(entry) => {
|
||||||
|
const isOwn = entry.user_id === auth?.user_id
|
||||||
const card = (
|
const card = (
|
||||||
<ReviewCard
|
<ReviewCard
|
||||||
movie={entry.movie}
|
movie={entry.movie}
|
||||||
@@ -118,9 +122,10 @@ function FeedTab() {
|
|||||||
userId={entry.user_id}
|
userId={entry.user_id}
|
||||||
isFederated={entry.is_federated}
|
isFederated={entry.is_federated}
|
||||||
actorUrl={entry.actor_url}
|
actorUrl={entry.actor_url}
|
||||||
|
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return entry.user_id === auth?.user_id ? (
|
return isOwn ? (
|
||||||
<SwipeToDelete
|
<SwipeToDelete
|
||||||
onDelete={() => deleteReview.mutate(entry.review.id)}
|
onDelete={() => deleteReview.mutate(entry.review.id)}
|
||||||
confirmTitle={t("feed.deleteReview")}
|
confirmTitle={t("feed.deleteReview")}
|
||||||
@@ -134,6 +139,16 @@ function FeedTab() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingEntry && (
|
||||||
|
<EditReviewSheet
|
||||||
|
key={editingEntry.review.id}
|
||||||
|
open={!!editingEntry}
|
||||||
|
onOpenChange={(open) => !open && setEditingEntry(null)}
|
||||||
|
movie={editingEntry.movie}
|
||||||
|
review={editingEntry.review}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
|
import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
|
||||||
import { BackButton } from "@/components/back-button"
|
import { BackButton } from "@/components/back-button"
|
||||||
import { StarDisplay } from "@/components/star-display"
|
import { StarDisplay } from "@/components/star-display"
|
||||||
|
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||||
import { RatingHistogram } from "@/components/rating-histogram"
|
import { RatingHistogram } from "@/components/rating-histogram"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||||
@@ -121,7 +122,10 @@ function MovieDetailPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<StarDisplay rating={r.rating} size="xs" />
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StarDisplay rating={r.rating} size="xs" />
|
||||||
|
{r.watch_medium && <WatchMediumBadge medium={r.watch_medium} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{r.comment && (
|
{r.comment && (
|
||||||
|
|||||||
@@ -113,15 +113,14 @@ function SettingsPage() {
|
|||||||
|
|
||||||
{isAdmin && <AdminActions />}
|
{isAdmin && <AdminActions />}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full rounded-xl bg-card p-3 text-sm font-medium text-red-400"
|
className="w-full justify-start gap-3 rounded-xl bg-card p-3 text-red-400 hover:text-red-300"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<LogOut className="size-4" />
|
||||||
<LogOut className="size-4" />
|
{t("settings.logOut")}
|
||||||
{t("settings.logOut")}
|
</Button>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ function WebhooksPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-bold">{t("webhooks.title")}</h1>
|
<h1 className="text-lg font-bold">{t("webhooks.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setOpen(true)} className="text-primary">
|
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} className="text-primary">
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
@@ -97,12 +97,14 @@ function WebhooksPage() {
|
|||||||
{new Date(t.created_at).toLocaleDateString()}
|
{new Date(t.created_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => remove.mutate(t.id)}
|
onClick={() => remove.mutate(t.id)}
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user