From 29cc68b07cbdee4eabf766cdb7bc8b4302b4dfff Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 00:01:14 +0200 Subject: [PATCH] feat: add WatchMedium field, general review editing, configurable deploy - 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 --- CONTEXT.md | 13 ++ .../activitypub/src/review_handler.rs | 1 + .../adapters/activitypub/src/tests/objects.rs | 2 + crates/adapters/export/src/tests/lib.rs | 1 + .../postgres-federation/src/ap_content.rs | 1 + .../postgres/migrations/0029_watch_medium.sql | 1 + crates/adapters/postgres/src/diary.rs | 13 +- crates/adapters/postgres/src/models.rs | 7 + crates/adapters/postgres/src/review.rs | 33 +++- .../sqlite-federation/src/ap_content.rs | 1 + .../sqlite/migrations/0029_watch_medium.sql | 1 + crates/adapters/sqlite/src/diary.rs | 14 +- crates/adapters/sqlite/src/models.rs | 7 + crates/adapters/sqlite/src/review.rs | 31 +++- .../templates/activity_feed.html | 3 + .../template-askama/templates/new_review.html | 13 ++ crates/api-types/src/diary.rs | 14 ++ crates/api-types/src/movies.rs | 4 + crates/application/src/diary/commands.rs | 11 ++ crates/application/src/diary/deps.rs | 5 + crates/application/src/diary/edit_review.rs | 60 +++++++ crates/application/src/diary/mod.rs | 1 + crates/application/src/diary/review_logger.rs | 9 +- .../src/diary/tests/delete_review.rs | 1 + .../src/diary/tests/edit_review.rs | 165 ++++++++++++++++++ .../application/src/diary/tests/log_review.rs | 3 + .../src/diary/tests/review_logger.rs | 7 + crates/application/src/import/execute.rs | 1 + .../application/src/integrations/confirm.rs | 1 + crates/domain/src/models/review.rs | 43 ++++- crates/domain/src/models/tests/mod.rs | 2 + crates/domain/src/models/tests/review.rs | 81 +++++++++ crates/domain/src/ports/diary.rs | 1 + .../src/services/tests/review_history.rs | 1 + crates/domain/src/testing/in_memory.rs | 8 + crates/domain/src/tests/value_objects.rs | 41 +++++ crates/domain/src/value_objects/review.rs | 66 +++++++ crates/presentation/src/forms.rs | 41 +++-- crates/presentation/src/handlers/diary.rs | 58 +++++- crates/presentation/src/handlers/movies.rs | 3 +- crates/presentation/src/mappers/movies.rs | 3 +- crates/presentation/src/routes.rs | 3 +- crates/presentation/src/tests/extractors.rs | 3 + crates/presentation/src/tests/forms.rs | 7 +- crates/tui/src/app.rs | 2 + crates/tui/src/tests/app.rs | 5 + crates/tui/src/tests/client.rs | 2 + deploy.sh | 24 ++- spa/src/aero-theme.css | 22 +++ spa/src/components/back-button.tsx | 9 +- spa/src/components/edit-review-sheet.tsx | 112 ++++++++++++ spa/src/components/editable-context-menu.tsx | 31 ++++ spa/src/components/log-sheet.tsx | 48 ++--- spa/src/components/review-card.tsx | 32 +++- spa/src/components/review-form-fields.tsx | 70 ++++++++ spa/src/components/search-overlay.tsx | 4 +- spa/src/components/ui/tooltip.tsx | 1 - spa/src/components/watch-medium-badge.tsx | 36 ++++ spa/src/components/watch-medium-picker.tsx | 55 ++++++ spa/src/hooks/use-diary.ts | 14 ++ spa/src/lib/api/client.ts | 11 ++ spa/src/lib/api/common.ts | 1 + spa/src/lib/api/diary.ts | 15 +- spa/src/lib/api/movies.ts | 1 + spa/src/lib/watch-mediums.ts | 26 +++ spa/src/locales/en.json | 17 ++ spa/src/routes/_app.tsx | 8 +- spa/src/routes/_app/diary.tsx | 45 ++++- spa/src/routes/_app/index.tsx | 17 +- spa/src/routes/_app/movies.$id.tsx | 6 +- spa/src/routes/_app/settings/index.tsx | 13 +- spa/src/routes/_app/settings/webhooks.tsx | 10 +- 72 files changed, 1298 insertions(+), 124 deletions(-) create mode 100644 CONTEXT.md create mode 100644 crates/adapters/postgres/migrations/0029_watch_medium.sql create mode 100644 crates/adapters/sqlite/migrations/0029_watch_medium.sql create mode 100644 crates/application/src/diary/edit_review.rs create mode 100644 crates/application/src/diary/tests/edit_review.rs create mode 100644 crates/domain/src/models/tests/review.rs create mode 100644 spa/src/components/edit-review-sheet.tsx create mode 100644 spa/src/components/editable-context-menu.tsx create mode 100644 spa/src/components/review-form-fields.tsx create mode 100644 spa/src/components/watch-medium-badge.tsx create mode 100644 spa/src/components/watch-medium-picker.tsx create mode 100644 spa/src/lib/watch-mediums.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6f13e33 --- /dev/null +++ b/CONTEXT.md @@ -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 diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 63bd495..458aa3f 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -132,6 +132,7 @@ impl ApObjectHandler for ReviewObjectHandler { source: ReviewSource::Remote { actor_url: actor_url_str, }, + watch_medium: None, }); self.review_store diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs index a4854bd..01165b6 100644 --- a/crates/adapters/activitypub/src/tests/objects.rs +++ b/crates/adapters/activitypub/src/tests/objects.rs @@ -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") .unwrap(), source: ReviewSource::Local, + watch_medium: None, }); let obj = review_to_ap_object( &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") .unwrap(), source: ReviewSource::Local, + watch_medium: None, }); let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); let obj = review_to_ap_object( diff --git a/crates/adapters/export/src/tests/lib.rs b/crates/adapters/export/src/tests/lib.rs index 03179f1..013a5b2 100644 --- a/crates/adapters/export/src/tests/lib.rs +++ b/crates/adapters/export/src/tests/lib.rs @@ -61,6 +61,7 @@ fn make_entry_full( .unwrap() .and_hms_opt(0, 0, 0) .unwrap(), + None, ) .unwrap(); DiaryEntry::new(movie, review) diff --git a/crates/adapters/postgres-federation/src/ap_content.rs b/crates/adapters/postgres-federation/src/ap_content.rs index 0138504..2170e98 100644 --- a/crates/adapters/postgres-federation/src/ap_content.rs +++ b/crates/adapters/postgres-federation/src/ap_content.rs @@ -106,6 +106,7 @@ impl ReviewRow { watched_at, created_at, source, + watch_medium: None, })) } } diff --git a/crates/adapters/postgres/migrations/0029_watch_medium.sql b/crates/adapters/postgres/migrations/0029_watch_medium.sql new file mode 100644 index 0000000..6492370 --- /dev/null +++ b/crates/adapters/postgres/migrations/0029_watch_medium.sql @@ -0,0 +1 @@ +ALTER TABLE reviews ADD COLUMN watch_medium TEXT; diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs index 89f6b60..40ae8f6 100644 --- a/crates/adapters/postgres/src/diary.rs +++ b/crates/adapters/postgres/src/diary.rs @@ -60,7 +60,8 @@ impl PostgresDiaryRepository { 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.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 INNER JOIN movies m ON m.id = r.movie_id ORDER BY {} @@ -93,7 +94,8 @@ impl PostgresDiaryRepository { 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.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 INNER JOIN movies m ON m.id = r.movie_id 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, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url + r.remote_actor_url, + r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id 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.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, r.remote_actor_url, + r.watch_medium, COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email FROM reviews r 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, to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url + r.remote_actor_url, + r.watch_medium FROM reviews r INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = $1 diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs index 373e076..c8944d8 100644 --- a/crates/adapters/postgres/src/models.rs +++ b/crates/adapters/postgres/src/models.rs @@ -90,6 +90,7 @@ pub(crate) struct ReviewRow { pub watched_at: String, pub created_at: String, pub remote_actor_url: Option, + pub watch_medium: Option, } impl ReviewRow { @@ -105,6 +106,7 @@ impl ReviewRow { None => ReviewSource::Local, Some(url) => ReviewSource::Remote { actor_url: url }, }; + let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?; Ok(Review::from_persistence(PersistedReview { id, movie_id, @@ -114,6 +116,7 @@ impl ReviewRow { watched_at, created_at, source, + watch_medium, })) } } @@ -134,6 +137,7 @@ pub(crate) struct DiaryRow { pub watched_at: String, pub created_at: String, pub remote_actor_url: Option, + pub watch_medium: Option, } impl DiaryRow { @@ -156,6 +160,7 @@ impl DiaryRow { watched_at: self.watched_at, created_at: self.created_at, remote_actor_url: self.remote_actor_url, + watch_medium: self.watch_medium, } .into_domain()?; Ok(DiaryEntry::new(movie, review)) @@ -178,6 +183,7 @@ pub(crate) struct FeedRow { pub watched_at: String, pub created_at: String, pub remote_actor_url: Option, + pub watch_medium: Option, pub user_email: String, } @@ -198,6 +204,7 @@ impl FeedRow { watched_at: self.watched_at, created_at: self.created_at, remote_actor_url: self.remote_actor_url, + watch_medium: self.watch_medium, } .into_domain()?; Ok(FeedEntry::new(diary, self.user_email)) diff --git a/crates/adapters/postgres/src/review.rs b/crates/adapters/postgres/src/review.rs index 1a299a6..d592898 100644 --- a/crates/adapters/postgres/src/review.rs +++ b/crates/adapters/postgres/src/review.rs @@ -41,8 +41,8 @@ impl ReviewRepository for PostgresReviewRepository { }; sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8)", + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)", ) .bind(&id) .bind(&movie_id) @@ -52,6 +52,7 @@ impl ReviewRepository for PostgresReviewRepository { .bind(&watched_at) .bind(&created_at) .bind(&remote_actor_url) + .bind(review.watch_medium().map(|wm| wm.to_string())) .execute(&self.pool) .await .map_err(Self::map_err)?; @@ -71,7 +72,8 @@ impl ReviewRepository for PostgresReviewRepository { "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(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", ) .bind(&id) @@ -82,6 +84,28 @@ impl ReviewRepository for PostgresReviewRepository { .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> { let id = review_id.value().to_string(); sqlx::query("DELETE FROM reviews WHERE id = $1") @@ -98,7 +122,8 @@ impl ReviewRepository for PostgresReviewRepository { "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(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", ) .bind(&uid) diff --git a/crates/adapters/sqlite-federation/src/ap_content.rs b/crates/adapters/sqlite-federation/src/ap_content.rs index 3a0e3db..f498b5a 100644 --- a/crates/adapters/sqlite-federation/src/ap_content.rs +++ b/crates/adapters/sqlite-federation/src/ap_content.rs @@ -106,6 +106,7 @@ impl ReviewRow { watched_at, created_at, source, + watch_medium: None, })) } } diff --git a/crates/adapters/sqlite/migrations/0029_watch_medium.sql b/crates/adapters/sqlite/migrations/0029_watch_medium.sql new file mode 100644 index 0000000..6492370 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0029_watch_medium.sql @@ -0,0 +1 @@ +ALTER TABLE reviews ADD COLUMN watch_medium TEXT; diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs index e9407b8..d0b272a 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/diary.rs @@ -57,7 +57,7 @@ impl SqliteDiaryRepository { }; let sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.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 INNER JOIN movies m ON m.id = r.movie_id ORDER BY {} @@ -87,7 +87,7 @@ impl SqliteDiaryRepository { }; let sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.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 INNER JOIN movies m ON m.id = r.movie_id WHERE r.movie_id = ? @@ -161,7 +161,7 @@ impl SqliteDiaryRepository { }; let sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.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 INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ?{remote_clause}{search_clause} @@ -308,7 +308,7 @@ impl DiaryRepository for SqliteDiaryRepository { let select_sql = format!( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - r.watched_at, r.created_at, r.remote_actor_url, + r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email FROM reviews r 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 rows = sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.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 INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? @@ -418,7 +418,7 @@ impl DiaryRepository for SqliteDiaryRepository { Box::pin(async_stream::stream! { let mut rows = sqlx::query_as::<_, DiaryRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url + r.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 INNER JOIN movies m ON m.id = r.movie_id WHERE r.user_id = ? @@ -475,7 +475,7 @@ impl DiaryRepository for SqliteDiaryRepository { let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - r.watched_at, r.created_at, r.remote_actor_url, + r.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 WHEN u.email IS NOT NULL THEN u.email ELSE r.user_id END AS user_email diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index 95df579..03c7e75 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -94,6 +94,7 @@ pub(crate) struct ReviewRow { pub watched_at: String, pub created_at: String, pub remote_actor_url: Option, + pub watch_medium: Option, } impl ReviewRow { @@ -109,6 +110,7 @@ impl ReviewRow { None => ReviewSource::Local, Some(url) => ReviewSource::Remote { actor_url: url }, }; + let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?; Ok(Review::from_persistence(PersistedReview { id, movie_id, @@ -118,6 +120,7 @@ impl ReviewRow { watched_at, created_at, source, + watch_medium, })) } } @@ -139,6 +142,7 @@ pub(crate) struct DiaryRow { pub watched_at: String, pub created_at: String, pub remote_actor_url: Option, + pub watch_medium: Option, } impl DiaryRow { @@ -162,6 +166,7 @@ impl DiaryRow { watched_at: self.watched_at, created_at: self.created_at, remote_actor_url: self.remote_actor_url, + watch_medium: self.watch_medium, } .into_domain()?; @@ -215,6 +220,7 @@ pub(crate) struct FeedRow { pub watched_at: String, pub created_at: String, pub remote_actor_url: Option, + pub watch_medium: Option, pub user_email: String, } @@ -235,6 +241,7 @@ impl FeedRow { watched_at: self.watched_at, created_at: self.created_at, remote_actor_url: self.remote_actor_url, + watch_medium: self.watch_medium, } .into_domain()?; Ok(FeedEntry::new(diary, self.user_email)) diff --git a/crates/adapters/sqlite/src/review.rs b/crates/adapters/sqlite/src/review.rs index d29bbd4..ccd01a5 100644 --- a/crates/adapters/sqlite/src/review.rs +++ b/crates/adapters/sqlite/src/review.rs @@ -41,8 +41,8 @@ impl ReviewRepository for SqliteReviewRepository { }; sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&movie_id) @@ -52,6 +52,7 @@ impl ReviewRepository for SqliteReviewRepository { .bind(&watched_at) .bind(&created_at) .bind(&remote_actor_url) + .bind(review.watch_medium().map(|wm| wm.to_string())) .execute(&self.pool) .await .map_err(Self::map_err)?; @@ -68,7 +69,7 @@ impl ReviewRepository for SqliteReviewRepository { async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { let id = review_id.value().to_string(); sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium FROM reviews WHERE id = ?", ) .bind(&id) @@ -79,6 +80,28 @@ impl ReviewRepository for SqliteReviewRepository { .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> { let id = review_id.value().to_string(); 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, DomainError> { let uid = user_id.value().to_string(); sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url + "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", ) .bind(&uid) diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/template-askama/templates/activity_feed.html index 8921bf1..fc1b07c 100644 --- a/crates/adapters/template-askama/templates/activity_feed.html +++ b/crates/adapters/template-askama/templates/activity_feed.html @@ -42,6 +42,9 @@ {% endfor %} + {% if let Some(wm) = entry.review().watch_medium() %} + {{ wm }} + {% endif %} {% if let Some(comment) = entry.review().comment() %}
{{ comment.value() }}
{% endif %} diff --git a/crates/adapters/template-askama/templates/new_review.html b/crates/adapters/template-askama/templates/new_review.html index c7cdd29..6df1a79 100644 --- a/crates/adapters/template-askama/templates/new_review.html +++ b/crates/adapters/template-askama/templates/new_review.html @@ -35,6 +35,19 @@ Comment
+ diff --git a/crates/api-types/src/diary.rs b/crates/api-types/src/diary.rs index 32f24f6..4a83e22 100644 --- a/crates/api-types/src/diary.rs +++ b/crates/api-types/src/diary.rs @@ -17,6 +17,8 @@ pub struct LogReviewRequest { #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, pub watched_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub watch_medium: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -77,6 +79,18 @@ pub struct ExportQueryParams { pub format: String, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct EditReviewRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub watched_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub watch_medium: Option>, +} + fn default_export_format() -> String { "csv".to_string() } diff --git a/crates/api-types/src/movies.rs b/crates/api-types/src/movies.rs index 36ca244..b75db6b 100644 --- a/crates/api-types/src/movies.rs +++ b/crates/api-types/src/movies.rs @@ -98,6 +98,8 @@ pub struct ReviewDto { pub rating: u8, pub comment: Option, pub watched_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub watch_medium: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -122,6 +124,8 @@ pub struct SocialReviewDto { pub comment: Option, pub watched_at: String, pub is_federated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub watch_medium: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/application/src/diary/commands.rs b/crates/application/src/diary/commands.rs index 0d4fb25..d7751c4 100644 --- a/crates/application/src/diary/commands.rs +++ b/crates/application/src/diary/commands.rs @@ -1,4 +1,5 @@ use chrono::NaiveDateTime; +use domain::value_objects::WatchMedium; use uuid::Uuid; pub struct MovieInput { @@ -15,6 +16,7 @@ pub struct LogReviewCommand { pub rating: u8, pub comment: Option, pub watched_at: NaiveDateTime, + pub watch_medium: Option, } pub struct DeleteReviewCommand { @@ -22,6 +24,15 @@ pub struct DeleteReviewCommand { pub requesting_user_id: Uuid, } +pub struct EditReviewCommand { + pub review_id: Uuid, + pub requesting_user_id: Uuid, + pub rating: Option, + pub comment: Option>, + pub watched_at: Option, + pub watch_medium: Option>, +} + #[derive(Clone)] pub struct SyncPosterCommand { pub movie_id: Uuid, diff --git a/crates/application/src/diary/deps.rs b/crates/application/src/diary/deps.rs index a19064d..d10054f 100644 --- a/crates/application/src/diary/deps.rs +++ b/crates/application/src/diary/deps.rs @@ -14,6 +14,11 @@ pub struct DeleteReviewDeps { pub event_publisher: Arc, } +pub struct EditReviewDeps { + pub review: Arc, + pub event_publisher: Arc, +} + pub struct GetMovieSocialPageDeps { pub movie: Arc, pub diary: Arc, diff --git a/crates/application/src/diary/edit_review.rs b/crates/application/src/diary/edit_review.rs new file mode 100644 index 0000000..3e4fbdf --- /dev/null +++ b/crates/application/src/diary/edit_review.rs @@ -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; diff --git a/crates/application/src/diary/mod.rs b/crates/application/src/diary/mod.rs index 78564f5..68c7d09 100644 --- a/crates/application/src/diary/mod.rs +++ b/crates/application/src/diary/mod.rs @@ -1,6 +1,7 @@ pub mod commands; pub mod delete_review; pub mod deps; +pub mod edit_review; pub mod export_diary; pub mod get_activity_feed; pub mod get_diary; diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs index 102fa0d..31b90f1 100644 --- a/crates/application/src/diary/review_logger.rs +++ b/crates/application/src/diary/review_logger.rs @@ -68,7 +68,14 @@ impl ReviewLogger for DefaultReviewLogger { 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 was_on_watchlist = self diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs index c7c40fc..e3fd004 100644 --- a/crates/application/src/diary/tests/delete_review.rs +++ b/crates/application/src/diary/tests/delete_review.rs @@ -32,6 +32,7 @@ fn make_review(movie_id: MovieId, user_id: UserId) -> Review { Rating::new(4).unwrap(), None, Utc::now().naive_utc(), + None, ) .unwrap() } diff --git a/crates/application/src/diary/tests/edit_review.rs b/crates/application/src/diary/tests/edit_review.rs new file mode 100644 index 0000000..bbd7db3 --- /dev/null +++ b/crates/application/src/diary/tests/edit_review.rs @@ -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, + Arc, + 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, + events: &Arc, +) -> 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)); +} diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs index 63bf619..77d242e 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/src/diary/tests/log_review.rs @@ -65,6 +65,7 @@ async fn test_log_review_creates_movie_and_review() { rating: 4, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; log_review::execute(&logger, cmd).await.unwrap(); @@ -97,6 +98,7 @@ async fn test_log_review_reuses_existing_movie() { rating: 5, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; log_review::execute(&logger, cmd).await.unwrap(); @@ -118,6 +120,7 @@ async fn test_log_review_with_invalid_rating_fails() { rating: 6, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; let result = log_review::execute(&logger, cmd).await; assert!(result.is_err(), "rating > 5 should fail"); diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index ee708cb..104be45 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -55,6 +55,7 @@ async fn logs_review_with_manual_movie() { rating: 4, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; logger.log_review(cmd).await.unwrap(); @@ -103,6 +104,7 @@ async fn removes_from_watchlist_on_review() { rating: 5, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; logger.log_review(cmd).await.unwrap(); @@ -140,6 +142,7 @@ async fn logs_review_with_existing_movie_by_id() { rating: 3, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; logger.log_review(cmd).await.unwrap(); @@ -168,6 +171,7 @@ async fn existing_movie_not_found_returns_error() { rating: 4, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; assert!(logger.log_review(cmd).await.is_err()); @@ -193,6 +197,7 @@ async fn invalid_rating_returns_error() { rating: 6, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; let result = logger.log_review(cmd).await; @@ -222,6 +227,7 @@ async fn watchlist_not_present_does_not_publish_removed() { rating: 4, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; logger.log_review(cmd).await.unwrap(); @@ -289,6 +295,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() { rating: 5, comment: None, watched_at: Utc::now().naive_utc(), + watch_medium: None, }; logger.log_review(cmd).await.unwrap(); diff --git a/crates/application/src/import/execute.rs b/crates/application/src/import/execute.rs index 8729fc8..8a23106 100644 --- a/crates/application/src/import/execute.rs +++ b/crates/application/src/import/execute.rs @@ -118,5 +118,6 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result, +} + +/// Partial update for a review. `None` = unchanged, `Some(None)` = clear, `Some(Some(v))` = set. +#[derive(Clone, Debug, Default)] +pub struct ReviewEdit { + pub rating: Option, + pub comment: Option>, + pub watched_at: Option, + pub watch_medium: Option>, } #[derive(Clone, Debug)] @@ -37,6 +47,7 @@ pub struct Review { watched_at: NaiveDateTime, created_at: NaiveDateTime, source: ReviewSource, + watch_medium: Option, } impl Review { @@ -46,6 +57,7 @@ impl Review { rating: Rating, comment: Option, watched_at: NaiveDateTime, + watch_medium: Option, ) -> Result { Ok(Self { id: ReviewId::generate(), @@ -56,6 +68,7 @@ impl Review { watched_at, created_at: Utc::now().naive_utc(), source: ReviewSource::Local, + watch_medium, }) } @@ -69,6 +82,27 @@ impl Review { watched_at: row.watched_at, created_at: row.created_at, 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 { &self.source } + pub fn watch_medium(&self) -> Option<&WatchMedium> { + self.watch_medium.as_ref() + } /// Returns [star1_filled, star2_filled, ..., star5_filled] pub fn stars(&self) -> [bool; 5] { let r = self.rating.value(); @@ -157,3 +194,7 @@ impl ReviewHistory { self.viewings.sort_by_key(|r| *r.watched_at()); } } + +#[cfg(test)] +#[path = "tests/review.rs"] +mod tests; diff --git a/crates/domain/src/models/tests/mod.rs b/crates/domain/src/models/tests/mod.rs index ffdf28e..e6b2d38 100644 --- a/crates/domain/src/models/tests/mod.rs +++ b/crates/domain/src/models/tests/mod.rs @@ -78,6 +78,7 @@ fn make_review() -> Review { Rating::new(4).unwrap(), Some(Comment::new("great".into()).unwrap()), chrono::Utc::now().naive_utc(), + None, ) .unwrap() } @@ -112,6 +113,7 @@ fn review_from_persistence() { source: ReviewSource::Remote { actor_url: "https://example.com/actor".into(), }, + watch_medium: None, }); assert_eq!(*r.id(), id); assert!(r.is_remote()); diff --git a/crates/domain/src/models/tests/review.rs b/crates/domain/src/models/tests/review.rs new file mode 100644 index 0000000..15b70f3 --- /dev/null +++ b/crates/domain/src/models/tests/review.rs @@ -0,0 +1,81 @@ +use chrono::NaiveDateTime; + +use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium}; + +use super::*; + +fn make_review(watch_medium: Option) -> 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)); +} diff --git a/crates/domain/src/ports/diary.rs b/crates/domain/src/ports/diary.rs index 383bd3a..b838fce 100644 --- a/crates/domain/src/ports/diary.rs +++ b/crates/domain/src/ports/diary.rs @@ -45,6 +45,7 @@ pub trait DiaryRepository: Send + Sync { pub trait ReviewRepository: Send + Sync { async fn save_review(&self, review: &Review) -> Result; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; + async fn update_review(&self, review: &Review) -> Result<(), DomainError>; async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError>; } diff --git a/crates/domain/src/services/tests/review_history.rs b/crates/domain/src/services/tests/review_history.rs index 8e0edac..c715096 100644 --- a/crates/domain/src/services/tests/review_history.rs +++ b/crates/domain/src/services/tests/review_history.rs @@ -28,6 +28,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime) Rating::new(rating).unwrap(), None, watched_at, + None, ) .unwrap() } diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index caf6ef2..a70644c 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -189,6 +189,14 @@ impl ReviewRepository for InMemoryReviewRepository { 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> { self.store.lock().unwrap().remove(&review_id.value()); Ok(()) diff --git a/crates/domain/src/tests/value_objects.rs b/crates/domain/src/tests/value_objects.rs index ea699de..eeca45e 100644 --- a/crates/domain/src/tests/value_objects.rs +++ b/crates/domain/src/tests/value_objects.rs @@ -158,3 +158,44 @@ fn password_value_preserves_content() { let raw = "supersecret!".to_string(); 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::().is_err()); + assert!("".parse::().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); + } +} diff --git a/crates/domain/src/value_objects/review.rs b/crates/domain/src/value_objects/review.rs index b7a0f8a..967fad5 100644 --- a/crates/domain/src/value_objects/review.rs +++ b/crates/domain/src/value_objects/review.rs @@ -1,5 +1,71 @@ +use std::fmt; +use std::str::FromStr; + 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 { + 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::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)] pub struct Rating(u8); diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index dbbe5ab..78465c3 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -37,6 +37,8 @@ pub struct LogReviewForm { #[serde(default, deserialize_with = "empty_string_as_none")] pub comment: Option, pub watched_at: String, + #[serde(default, deserialize_with = "empty_string_as_none")] + pub watch_medium: Option, #[serde(rename = "_csrf", default)] pub csrf_token: String, } @@ -170,6 +172,7 @@ pub struct LogReviewData { pub rating: u8, pub comment: Option, pub watched_at: NaiveDateTime, + pub watch_medium: Option, } #[derive(Debug)] @@ -182,18 +185,23 @@ impl TryFrom for LogReviewData { type Error = ParseReviewError; fn try_from(form: LogReviewForm) -> Result { - let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S") - .or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M")) - .or_else(|_| { - chrono::NaiveDate::parse_from_str(&form.watched_at, "%Y-%m-%d") - .map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid")) - }) + let watched_at = + domain::value_objects::parse_watched_at(&form.watched_at).map_err(|_| { + ParseReviewError { + field: "watched_at", + 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 { - field: "watched_at", - message: format!( - "invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]", - form.watched_at - ), + field: "watch_medium", + message: "invalid watch medium".into(), })?; Ok(Self { external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()), @@ -203,6 +211,7 @@ impl TryFrom for LogReviewData { rating: form.rating, comment: form.comment, watched_at, + watch_medium, }) } } @@ -211,12 +220,8 @@ impl TryFrom for LogReviewData { type Error = DomainError; fn try_from(req: LogReviewRequest) -> Result { - let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S") - .map_err(|_| { - DomainError::ValidationError( - "invalid watched_at; expected YYYY-MM-DDTHH:MM:SS".into(), - ) - })?; + let watched_at = domain::value_objects::parse_watched_at(&req.watched_at)?; + let watch_medium = req.watch_medium.map(|s| s.parse()).transpose()?; Ok(Self { external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()), manual_title: req.manual_title, @@ -225,6 +230,7 @@ impl TryFrom for LogReviewData { rating: req.rating, comment: req.comment, watched_at, + watch_medium, }) } } @@ -243,6 +249,7 @@ impl LogReviewData { rating: self.rating, comment: self.comment, watched_at: self.watched_at, + watch_medium: self.watch_medium, } } } diff --git a/crates/presentation/src/handlers/diary.rs b/crates/presentation/src/handlers/diary.rs index f29830d..f1d4c9c 100644 --- a/crates/presentation/src/handlers/diary.rs +++ b/crates/presentation/src/handlers/diary.rs @@ -9,10 +9,11 @@ use futures::StreamExt; use uuid::Uuid; use application::diary::{ - commands::DeleteReviewCommand, + commands::{DeleteReviewCommand, EditReviewCommand}, delete_review, - deps::{DeleteReviewDeps, GetActivityFeedDeps}, - export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, log_review, + deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps}, + edit_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, + log_review, queries::{ExportQuery, GetActivityFeedQuery}, }; use domain::models::ExportFormat; @@ -27,7 +28,7 @@ use crate::{ }; use api_types::{ ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse, - ExportQueryParams, LogReviewRequest, + EditReviewRequest, ExportQueryParams, LogReviewRequest, }; use template_askama::{ActivityFeedTemplate, NewReviewTemplate, build_page_items}; @@ -121,6 +122,55 @@ pub async fn delete_review( 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, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, + Json(req): Json, +) -> Result { + 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::()) + .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( get, path = "/api/v1/diary/export", params(ExportQueryParams), diff --git a/crates/presentation/src/handlers/movies.rs b/crates/presentation/src/handlers/movies.rs index 8fa582b..dcebf2a 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/presentation/src/handlers/movies.rs @@ -183,8 +183,9 @@ pub async fn get_movie_detail( user_display: e.user_display_name().to_string(), rating: e.review().rating().value(), 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(), + watch_medium: e.review().watch_medium().map(|wm| wm.to_string()), }) .collect(), total_count: result.reviews.total_count, diff --git a/crates/presentation/src/mappers/movies.rs b/crates/presentation/src/mappers/movies.rs index 14c8b6b..57b7cfb 100644 --- a/crates/presentation/src/mappers/movies.rs +++ b/crates/presentation/src/mappers/movies.rs @@ -36,7 +36,8 @@ pub fn review_to_dto(review: &Review) -> ReviewDto { id: review.id().value(), rating: review.rating().value(), 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()), } } diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 79ecd79..4eea57f 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -262,6 +262,7 @@ fn cors_layer() -> CorsLayer { Method::GET, Method::POST, Method::PUT, + Method::PATCH, Method::DELETE, Method::OPTIONS, ]) @@ -318,7 +319,7 @@ fn api_routes(rate_limit: u64) -> Router { .route("/reviews", routing::post(handlers::diary::post_review)) .route( "/reviews/{id}", - routing::delete(handlers::diary::delete_review), + routing::delete(handlers::diary::delete_review).patch(handlers::diary::patch_review), ) .route( "/movies/{id}/sync-poster", diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 92b3fd0..8a58eb9 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -92,6 +92,9 @@ impl ReviewRepository for Panic { async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { panic!() } + async fn update_review(&self, _: &Review) -> Result<(), DomainError> { + panic!() + } async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() } diff --git a/crates/presentation/src/tests/forms.rs b/crates/presentation/src/tests/forms.rs index eb17d4f..1a59679 100644 --- a/crates/presentation/src/tests/forms.rs +++ b/crates/presentation/src/tests/forms.rs @@ -9,6 +9,7 @@ fn make_form(watched_at: &str) -> LogReviewForm { rating: 4, comment: None, watched_at: watched_at.to_string(), + watch_medium: None, csrf_token: String::new(), } } @@ -22,6 +23,7 @@ fn make_request(watched_at: &str) -> LogReviewRequest { rating: 4, comment: None, watched_at: watched_at.to_string(), + watch_medium: None, } } @@ -49,8 +51,9 @@ fn api_accepts_datetime_with_seconds() { } #[test] -fn api_rejects_datetime_without_seconds() { - assert!(LogReviewData::try_from(make_request("2024-03-15T20:30")).is_err()); +fn api_accepts_datetime_without_seconds() { + 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] diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index d3fee26..c8926d8 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -364,6 +364,7 @@ pub fn parse_csv(content: &str) -> Vec { Some(comment) }, watched_at, + watch_medium: None, }), }); } @@ -856,6 +857,7 @@ pub fn update(app: &mut App, action: Action) -> Vec { rating, comment, watched_at, + watch_medium: None, }; app.loading = true; return vec![Command::CreateReview(req)]; diff --git a/crates/tui/src/tests/app.rs b/crates/tui/src/tests/app.rs index 1cfae76..cde757c 100644 --- a/crates/tui/src/tests/app.rs +++ b/crates/tui/src/tests/app.rs @@ -54,6 +54,7 @@ fn diary_entry() -> DiaryEntryDto { rating: 5, comment: None, 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, comment: None, 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, comment: None, watched_at: "2024-01-01T00:00:00".into(), + watch_medium: None, }, LogReviewRequest { external_metadata_id: None, @@ -412,6 +415,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() { rating: 4, comment: None, watched_at: "2024-01-02T00:00:00".into(), + watch_medium: None, }, ]; m.bulk_import.results = vec![None, None]; @@ -440,6 +444,7 @@ fn bulk_item_done_last_item_transitions_to_done() { rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into(), + watch_medium: None, }]; m.bulk_import.results = vec![None]; } diff --git a/crates/tui/src/tests/client.rs b/crates/tui/src/tests/client.rs index 8da1d49..0eeecf3 100644 --- a/crates/tui/src/tests/client.rs +++ b/crates/tui/src/tests/client.rs @@ -23,6 +23,7 @@ fn log_review_request_skips_none_fields() { rating: 5, comment: None, watched_at: "2024-01-15T20:00:00".into(), + watch_medium: None, }; let json = serde_json::to_string(&req).unwrap(); assert!(!json.contains("external_metadata_id")); @@ -42,6 +43,7 @@ fn log_review_request_includes_director_when_set() { rating: 5, comment: None, watched_at: "2024-01-15T20:00:00".into(), + watch_medium: None, }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"manual_director\":\"Denis Villeneuve\"")); diff --git a/deploy.sh b/deploy.sh index b595f05..5e6f8ee 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,9 +1,25 @@ #!/usr/bin/env bash 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 \ - --build-arg FEATURES=sqlite,sqlite-federation,nats \ - -t "$IMAGE" --push . -echo "pushed $IMAGE" + --build-arg FEATURES="$features" \ + -t "$image" --push . +echo "pushed $image" diff --git a/spa/src/aero-theme.css b/spa/src/aero-theme.css index 38cc9e2..fd9e83c 100644 --- a/spa/src/aero-theme.css +++ b/spa/src/aero-theme.css @@ -301,6 +301,28 @@ body > #root { 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 */ .aero-star-filled { filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary)); diff --git a/spa/src/components/back-button.tsx b/spa/src/components/back-button.tsx index d0e9529..f61b15f 100644 --- a/spa/src/components/back-button.tsx +++ b/spa/src/components/back-button.tsx @@ -1,17 +1,20 @@ import { useRouter } from "@tanstack/react-router" import { useTranslation } from "react-i18next" import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" export function BackButton() { const { t } = useTranslation() const router = useRouter() return ( - + ) } diff --git a/spa/src/components/edit-review-sheet.tsx b/spa/src/components/edit-review-sheet.tsx new file mode 100644 index 0000000..04384fe --- /dev/null +++ b/spa/src/components/edit-review-sheet.tsx @@ -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(() => parseLocalDate(review.watched_at)) + const [dateChanged, setDateChanged] = useState(false) + const [watchMedium, setWatchMedium] = useState(review.watch_medium) + const editMutation = useEditReview() + + function handleDateChange(d: Date) { + setWatchedAt(d) + setDateChanged(true) + } + + function handleSubmit() { + if (!rating) return + + const data: Partial = {} + 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 ( + + + {t("editReview.title")} +
+
+
+ {movie.poster_path && } +
+
+

{movie.title}

+

{movie.release_year}{movie.director && ` · ${movie.director}`}

+
+
+ + + + +
+
+
+ ) +} diff --git a/spa/src/components/editable-context-menu.tsx b/spa/src/components/editable-context-menu.tsx new file mode 100644 index 0000000..c1efed3 --- /dev/null +++ b/spa/src/components/editable-context-menu.tsx @@ -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 ( + + +
{children}
+
+ + + + {t("editReview.title")} + + +
+ ) +} diff --git a/spa/src/components/log-sheet.tsx b/spa/src/components/log-sheet.tsx index 7d1c4d1..677d105 100644 --- a/spa/src/components/log-sheet.tsx +++ b/spa/src/components/log-sheet.tsx @@ -1,14 +1,9 @@ import { useState } from "react" import { useTranslation } from "react-i18next" import { VisuallyHidden } from "radix-ui" -import { CalendarIcon } from "lucide-react" -import { format } from "date-fns" import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer" 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 { ReviewFormFields } from "@/components/review-form-fields" import { SearchOverlay } from "@/components/search-overlay" import type { MovieSelection } from "@/components/search-overlay" import { useLogReview } from "@/hooks/use-diary" @@ -27,6 +22,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) { const [rating, setRating] = useState(0) const [comment, setComment] = useState("") const [watchedAt, setWatchedAt] = useState(new Date()) + const [watchMedium, setWatchMedium] = useState() const logMutation = useLogReview() function reset() { @@ -34,6 +30,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) { setRating(0) setComment("") setWatchedAt(new Date()) + setWatchMedium(undefined) } function handleClose() { @@ -52,6 +49,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) { rating, comment: comment || undefined, watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!, + watch_medium: watchMedium, }, { onSuccess: () => { @@ -85,34 +83,16 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) { -
-

{t("logReview.yourRating")}

-
-
- -