Compare commits
11 Commits
29cc68b07c
...
26152660bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 26152660bb | |||
| c8f93bdd35 | |||
| 081a20ae31 | |||
| cde2f5aaae | |||
| f584bcd724 | |||
| 925f74bb3d | |||
| 306f4489fd | |||
| 5d4622e046 | |||
| 2e2adef5e0 | |||
| 206ad44e82 | |||
| 8eecd06fb8 |
@@ -47,8 +47,8 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating
|
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
|
||||||
- Immutable append-only viewing ledger (tracks re-watches)
|
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
|
||||||
- Background poster fetching and storage (local filesystem or S3-compatible)
|
- Background poster fetching and storage (local filesystem or S3-compatible)
|
||||||
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
|
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
|
||||||
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
|
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ graph TB
|
|||||||
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
||||||
subgraph UseCases["Use Cases"]
|
subgraph UseCases["Use Cases"]
|
||||||
UC_AUTH["auth<br/>login, register"]
|
UC_AUTH["auth<br/>login, register"]
|
||||||
UC_DIARY["diary<br/>log_review, get_diary,<br/>get_activity_feed, export"]
|
UC_DIARY["diary<br/>log_review, edit_review,<br/>get_diary, get_activity_feed,<br/>export"]
|
||||||
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search,<br/>merge_duplicates"]
|
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search,<br/>merge_duplicates"]
|
||||||
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
|
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
|
||||||
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
|
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
|
||||||
@@ -50,7 +50,7 @@ graph TB
|
|||||||
direction TB
|
direction TB
|
||||||
subgraph Models["Models"]
|
subgraph Models["Models"]
|
||||||
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
||||||
M_REVIEW["Review, DiaryEntry,<br/>FeedEntry"]
|
M_REVIEW["Review, ReviewEdit,<br/>DiaryEntry, FeedEntry"]
|
||||||
M_USER["User, UserSummary"]
|
M_USER["User, UserSummary"]
|
||||||
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
||||||
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
||||||
@@ -69,7 +69,7 @@ graph TB
|
|||||||
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
||||||
end
|
end
|
||||||
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>UserDeleted, UserAccountMoved,<br/>SearchReindexRequested, ...</i>"]
|
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>UserDeleted, UserAccountMoved,<br/>SearchReindexRequested, ...</i>"]
|
||||||
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, Password, ...</i>"]
|
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph ApiTypes["api-types (0 domain deps)"]
|
subgraph ApiTypes["api-types (0 domain deps)"]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub use k_ap::{
|
|||||||
|
|
||||||
pub use event_handler::ActivityPubEventHandler;
|
pub use event_handler::ActivityPubEventHandler;
|
||||||
pub use port::{ActivityPubPort, NoopActivityPubService};
|
pub use port::{ActivityPubPort, NoopActivityPubService};
|
||||||
pub use remote_review_repository::RemoteReviewRepository;
|
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||||
pub use review_handler::ReviewObjectHandler;
|
pub use review_handler::ReviewObjectHandler;
|
||||||
pub use user_adapter::DomainUserRepoAdapter;
|
pub use user_adapter::DomainUserRepoAdapter;
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ pub struct ReviewObject {
|
|||||||
pub(crate) rating: u8,
|
pub(crate) rating: u8,
|
||||||
pub(crate) comment: Option<String>,
|
pub(crate) comment: Option<String>,
|
||||||
pub(crate) watched_at: DateTime<Utc>,
|
pub(crate) watched_at: DateTime<Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub(crate) watch_medium: Option<String>,
|
||||||
/// Discriminator so Movies Diary instances detect this as a review Note.
|
/// Discriminator so Movies Diary instances detect this as a review Note.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) review: bool,
|
pub(crate) review: bool,
|
||||||
@@ -135,6 +137,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
|||||||
rating: review.rating().value(),
|
rating: review.rating().value(),
|
||||||
comment: comment_text,
|
comment: comment_text,
|
||||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||||
|
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||||
review: true,
|
review: true,
|
||||||
attachment,
|
attachment,
|
||||||
tag,
|
tag,
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::models::Review;
|
use domain::models::Review;
|
||||||
|
|
||||||
|
pub struct RemoteReviewUpdate<'a> {
|
||||||
|
pub ap_id: &'a str,
|
||||||
|
pub actor_url: &'a str,
|
||||||
|
pub rating: u8,
|
||||||
|
pub comment: Option<&'a str>,
|
||||||
|
pub watched_at: NaiveDateTime,
|
||||||
|
pub poster_url: Option<&'a str>,
|
||||||
|
pub watch_medium: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RemoteReviewRepository: Send + Sync {
|
pub trait RemoteReviewRepository: Send + Sync {
|
||||||
async fn save_remote_review(
|
async fn save_remote_review(
|
||||||
@@ -17,15 +27,7 @@ pub trait RemoteReviewRepository: Send + Sync {
|
|||||||
|
|
||||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
||||||
|
|
||||||
async fn update_remote_review(
|
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> Result<()>;
|
||||||
&self,
|
|
||||||
ap_id: &str,
|
|
||||||
actor_url: &str,
|
|
||||||
rating: u8,
|
|
||||||
comment: Option<&str>,
|
|
||||||
watched_at: NaiveDateTime,
|
|
||||||
poster_url: Option<&str>,
|
|
||||||
) -> Result<()>;
|
|
||||||
|
|
||||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
|
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
));
|
));
|
||||||
let rating = Rating::new(obj.rating.min(5))?;
|
let rating = Rating::new(obj.rating.min(5))?;
|
||||||
let comment = obj.comment.map(Comment::new).transpose()?;
|
let comment = obj.comment.map(Comment::new).transpose()?;
|
||||||
|
let watch_medium = obj
|
||||||
|
.watch_medium
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
|
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
|
||||||
id: review_id,
|
id: review_id,
|
||||||
@@ -132,7 +138,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
source: ReviewSource::Remote {
|
source: ReviewSource::Remote {
|
||||||
actor_url: actor_url_str,
|
actor_url: actor_url_str,
|
||||||
},
|
},
|
||||||
watch_medium: None,
|
watch_medium,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
@@ -182,14 +188,15 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
.update_remote_review(
|
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
|
||||||
ap_id.as_str(),
|
ap_id: ap_id.as_str(),
|
||||||
actor_url.as_str(),
|
actor_url: actor_url.as_str(),
|
||||||
obj.rating.min(5),
|
rating: obj.rating.min(5),
|
||||||
obj.comment.as_deref(),
|
comment: obj.comment.as_deref(),
|
||||||
obj.watched_at.naive_utc(),
|
watched_at: obj.watched_at.naive_utc(),
|
||||||
obj.poster_url.as_deref(),
|
poster_url: obj.poster_url.as_deref(),
|
||||||
)
|
watch_medium: obj.watch_medium.as_deref(),
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use activitypub::RemoteReviewRepository;
|
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::models::{Review, ReviewSource};
|
use domain::models::{Review, ReviewSource};
|
||||||
@@ -71,35 +71,28 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_remote_review(
|
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||||
&self,
|
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||||
ap_id: &str,
|
|
||||||
actor_url: &str,
|
|
||||||
rating: u8,
|
|
||||||
comment: Option<&str>,
|
|
||||||
watched_at: chrono::NaiveDateTime,
|
|
||||||
poster_url: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let watched_at_str = datetime_to_str(&watched_at);
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz
|
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4
|
||||||
WHERE ap_id = $4 AND remote_actor_url = $5",
|
WHERE ap_id = $5 AND remote_actor_url = $6",
|
||||||
)
|
)
|
||||||
.bind(rating as i64)
|
.bind(u.rating as i64)
|
||||||
.bind(comment)
|
.bind(u.comment)
|
||||||
.bind(&watched_at_str)
|
.bind(&watched_at_str)
|
||||||
.bind(ap_id)
|
.bind(u.watch_medium)
|
||||||
.bind(actor_url)
|
.bind(u.ap_id)
|
||||||
|
.bind(u.actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(url) = poster_url {
|
if let Some(url) = u.poster_url {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE movies SET poster_path = $1
|
"UPDATE movies SET poster_path = $1
|
||||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
|
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
|
||||||
)
|
)
|
||||||
.bind(url)
|
.bind(url)
|
||||||
.bind(ap_id)
|
.bind(u.ap_id)
|
||||||
.bind(actor_url)
|
.bind(u.actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,7 +343,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
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,
|
r.watch_medium,
|
||||||
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
|
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||||
|
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
@@ -522,12 +523,13 @@ 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,
|
||||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
|
r.watch_medium,
|
||||||
WHEN u.email IS NOT NULL THEN u.email
|
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||||
ELSE r.user_id END AS user_email
|
ELSE COALESCE(u.email, r.user_id) END 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
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
||||||
WHERE r.movie_id = $1
|
WHERE r.movie_id = $1
|
||||||
ORDER BY r.watched_at DESC
|
ORDER BY r.watched_at DESC
|
||||||
LIMIT $2 OFFSET $3",
|
LIMIT $2 OFFSET $3",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use activitypub::RemoteReviewRepository;
|
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::models::{Review, ReviewSource};
|
use domain::models::{Review, ReviewSource};
|
||||||
@@ -71,35 +71,28 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_remote_review(
|
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||||
&self,
|
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||||
ap_id: &str,
|
|
||||||
actor_url: &str,
|
|
||||||
rating: u8,
|
|
||||||
comment: Option<&str>,
|
|
||||||
watched_at: chrono::NaiveDateTime,
|
|
||||||
poster_url: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let watched_at_str = datetime_to_str(&watched_at);
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?
|
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?
|
||||||
WHERE ap_id = ? AND remote_actor_url = ?",
|
WHERE ap_id = ? AND remote_actor_url = ?",
|
||||||
)
|
)
|
||||||
.bind(rating as i64)
|
.bind(u.rating as i64)
|
||||||
.bind(comment)
|
.bind(u.comment)
|
||||||
.bind(&watched_at_str)
|
.bind(&watched_at_str)
|
||||||
.bind(ap_id)
|
.bind(u.watch_medium)
|
||||||
.bind(actor_url)
|
.bind(u.ap_id)
|
||||||
|
.bind(u.actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(url) = poster_url {
|
if let Some(url) = u.poster_url {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE movies SET poster_path = ?
|
"UPDATE movies SET poster_path = ?
|
||||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)",
|
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)",
|
||||||
)
|
)
|
||||||
.bind(url)
|
.bind(url)
|
||||||
.bind(ap_id)
|
.bind(u.ap_id)
|
||||||
.bind(actor_url)
|
.bind(u.actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,7 +309,8 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
"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.watch_medium,
|
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
|
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||||
|
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
@@ -476,12 +477,12 @@ impl DiaryRepository for SqliteDiaryRepository {
|
|||||||
"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.watch_medium,
|
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 COALESCE(a.handle, r.remote_actor_url)
|
||||||
WHEN u.email IS NOT NULL THEN u.email
|
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||||
ELSE r.user_id END 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
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
||||||
WHERE r.movie_id = ?
|
WHERE r.movie_id = ?
|
||||||
ORDER BY r.watched_at DESC
|
ORDER BY r.watched_at DESC
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if let Some(wm) = entry.review().watch_medium() %}
|
{% if let Some(wm) = entry.review().watch_medium() %}
|
||||||
<span class="watch-medium">{{ wm }}</span>
|
<span class="watch-medium">{{ wm.label() }}</span>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub struct DiaryQueryParams {
|
|||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
pub sort_by: Option<String>,
|
pub sort_by: Option<String>,
|
||||||
pub movie_id: Option<Uuid>,
|
pub movie_id: Option<Uuid>,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ pub async fn execute(
|
|||||||
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||||
page,
|
page,
|
||||||
movie_id,
|
movie_id,
|
||||||
user_id,
|
user_id: user_id.clone(),
|
||||||
search: None,
|
search: None,
|
||||||
include_remote: false,
|
include_remote: user_id.is_some(),
|
||||||
};
|
};
|
||||||
|
|
||||||
diary.query_diary(&filter).await
|
diary.query_diary(&filter).await
|
||||||
|
|||||||
@@ -56,7 +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,
|
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
||||||
};
|
};
|
||||||
|
|
||||||
review_logger.log_review(review_cmd).await?;
|
review_logger.log_review(review_cmd).await?;
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ impl fmt::Display for WatchMedium {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WatchMedium {
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Cinema => "Cinema",
|
||||||
|
Self::Streaming => "Streaming",
|
||||||
|
Self::TV => "TV",
|
||||||
|
Self::PhysicalMedia => "Physical Media",
|
||||||
|
Self::Download => "Download",
|
||||||
|
Self::MediaServer => "Media Server",
|
||||||
|
Self::Other => "Other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromStr for WatchMedium {
|
impl FromStr for WatchMedium {
|
||||||
type Err = DomainError;
|
type Err = DomainError;
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery {
|
|||||||
_ => SortDirection::Descending,
|
_ => SortDirection::Descending,
|
||||||
}),
|
}),
|
||||||
movie_id: p.movie_id,
|
movie_id: p.movie_id,
|
||||||
user_id: None,
|
user_id: p.user_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ fn sort_by_asc_string_becomes_ascending() {
|
|||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
movie_id: None,
|
movie_id: None,
|
||||||
|
user_id: None,
|
||||||
};
|
};
|
||||||
let query = to_diary_query(params);
|
let query = to_diary_query(params);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -99,6 +100,7 @@ fn sort_by_other_string_becomes_descending() {
|
|||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
movie_id: None,
|
movie_id: None,
|
||||||
|
user_id: None,
|
||||||
};
|
};
|
||||||
let query = to_diary_query(params);
|
let query = to_diary_query(params);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type MovieCardProps = {
|
|||||||
movie: MovieDto
|
movie: MovieDto
|
||||||
rating?: number
|
rating?: number
|
||||||
comment?: string
|
comment?: string
|
||||||
subtitle?: string
|
subtitle?: React.ReactNode
|
||||||
variant?: "compact" | "full"
|
variant?: "compact" | "full"
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { EmptyState } from "@/components/empty-state"
|
|||||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||||
import { VirtualList } from "@/components/virtual-list"
|
import { VirtualList } from "@/components/virtual-list"
|
||||||
import { useInfiniteDiary } from "@/hooks/use-diary"
|
import { useInfiniteDiary } from "@/hooks/use-diary"
|
||||||
import { timeAgo } from "@/lib/date"
|
import { TimeAgo } from "@/components/time-ago"
|
||||||
import type { UserProfileResponse } from "@/lib/api/users"
|
import type { UserProfileResponse } from "@/lib/api/users"
|
||||||
|
|
||||||
type ProfileViewProps = {
|
type ProfileViewProps = {
|
||||||
@@ -141,10 +141,10 @@ function StatCell({ label, value }: { label: string; value: string | number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiaryTab({ sortBy, search }: { sortBy: string; userId?: string; search?: string }) {
|
function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string; search?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: sortBy, movie_id: undefined })
|
useInfiniteDiary({ sort_by: sortBy, user_id: userId })
|
||||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? items.filter((e) =>
|
? items.filter((e) =>
|
||||||
@@ -168,7 +168,7 @@ function DiaryTab({ sortBy, search }: { sortBy: string; userId?: string; search?
|
|||||||
movie={e.movie}
|
movie={e.movie}
|
||||||
rating={e.review.rating}
|
rating={e.review.rating}
|
||||||
comment={e.review.comment}
|
comment={e.review.comment}
|
||||||
subtitle={t("profile.watchedAgo", { when: timeAgo(e.review.watched_at) })}
|
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { Globe, Pencil } from "lucide-react"
|
import { Globe, Pencil } from "lucide-react"
|
||||||
import { timeAgo } from "@/lib/date"
|
import { TimeAgo } from "@/components/time-ago"
|
||||||
import { StarDisplay } from "@/components/star-display"
|
import { StarDisplay } from "@/components/star-display"
|
||||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||||
@@ -17,9 +17,10 @@ type ReviewCardProps = {
|
|||||||
isFederated?: boolean
|
isFederated?: boolean
|
||||||
actorUrl?: string
|
actorUrl?: string
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
|
onShowDetail?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit }: ReviewCardProps) {
|
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit, onShowDetail }: ReviewCardProps) {
|
||||||
const card = (
|
const card = (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
<CardContent className="flex gap-3">
|
<CardContent className="flex gap-3">
|
||||||
@@ -42,7 +43,7 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
|||||||
)}
|
)}
|
||||||
{isFederated && <Globe className="size-3 text-muted-foreground/60" />}
|
{isFederated && <Globe className="size-3 text-muted-foreground/60" />}
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{timeAgo(review.watched_at)}</span>
|
<TimeAgo date={review.watched_at} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -59,7 +60,17 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
|||||||
<StarDisplay rating={review.rating} />
|
<StarDisplay rating={review.rating} />
|
||||||
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||||
</div>
|
</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"
|
||||||
|
role={onShowDetail ? "button" : undefined}
|
||||||
|
tabIndex={onShowDetail ? 0 : undefined}
|
||||||
|
onClick={onShowDetail}
|
||||||
|
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||||
|
>
|
||||||
|
{review.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
51
spa/src/components/review-detail-sheet.tsx
Normal file
51
spa/src/components/review-detail-sheet.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { VisuallyHidden } from "radix-ui"
|
||||||
|
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||||
|
import { StarDisplay } from "@/components/star-display"
|
||||||
|
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||||
|
import { shortDate } from "@/lib/date"
|
||||||
|
import { posterUrl } from "@/lib/api/client"
|
||||||
|
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
||||||
|
|
||||||
|
type ReviewDetailSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
movie: MovieDto
|
||||||
|
review: ReviewDto
|
||||||
|
userName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName }: ReviewDetailSheetProps) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent className="mx-auto max-w-lg">
|
||||||
|
<VisuallyHidden.Root><DrawerTitle>{movie.title}</DrawerTitle></VisuallyHidden.Root>
|
||||||
|
<div className="p-5 pb-8">
|
||||||
|
<div className="mb-4 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>
|
||||||
|
{userName && <p className="mt-1 text-xs text-muted-foreground">{userName}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<StarDisplay rating={review.rating} />
|
||||||
|
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||||
|
<span className="text-xs text-muted-foreground">{shortDate(review.watched_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{review.comment && (
|
||||||
|
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{review.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback, useRef } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { CalendarIcon } from "lucide-react"
|
import { CalendarIcon } from "lucide-react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
@@ -38,7 +39,7 @@ export function ReviewFormFields({
|
|||||||
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
|
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea value={comment} onChange={(e) => onCommentChange(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
|
<AutoGrowTextarea value={comment} onChange={onCommentChange} placeholder={t("logReview.commentPlaceholder")} className="mb-5" />
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
||||||
@@ -68,3 +69,38 @@ export function ReviewFormFields({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoGrowTextarea({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const handleInput = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = "auto"
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
handleInput()
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
rows={2}
|
||||||
|
style={{ overflow: "hidden", resize: "none" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
18
spa/src/components/time-ago.tsx
Normal file
18
spa/src/components/time-ago.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { timeAgo, shortDate } from "@/lib/date"
|
||||||
|
|
||||||
|
type TimeAgoProps = {
|
||||||
|
date: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeAgo({ date, className }: TimeAgoProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<time dateTime={date} className={className}>{timeAgo(date)}</time>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{shortDate(date)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||||
|
|
||||||
@@ -22,7 +17,6 @@ export function WatchMediumBadge({ medium, className }: WatchMediumBadgeProps) {
|
|||||||
const Icon = entry.icon
|
const Icon = entry.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="icon" className={cn("size-6", className)} aria-label={t(entry.labelKey)}>
|
<Button type="button" variant="ghost" size="icon" className={cn("size-6", className)} aria-label={t(entry.labelKey)}>
|
||||||
@@ -31,6 +25,5 @@ export function WatchMediumBadge({ medium, className }: WatchMediumBadgeProps) {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t(entry.labelKey)}</TooltipContent>
|
<TooltipContent>{t(entry.labelKey)}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||||
|
|
||||||
@@ -22,7 +17,6 @@ export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
|
|||||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
{t("watchMedium.label")}
|
{t("watchMedium.label")}
|
||||||
</p>
|
</p>
|
||||||
<TooltipProvider>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => {
|
{WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => {
|
||||||
const selected = value === val
|
const selected = value === val
|
||||||
@@ -49,7 +43,6 @@ export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const diaryQueryParamsSchema = z.object({
|
|||||||
offset: z.number().optional(),
|
offset: z.number().optional(),
|
||||||
sort_by: z.string().optional(),
|
sort_by: z.string().optional(),
|
||||||
movie_id: z.string().uuid().optional(),
|
movie_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
})
|
})
|
||||||
export type DiaryQueryParams = z.infer<typeof diaryQueryParamsSchema>
|
export type DiaryQueryParams = z.infer<typeof diaryQueryParamsSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRootRoute, Outlet } from "@tanstack/react-router"
|
import { createRootRoute, Outlet } from "@tanstack/react-router"
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@@ -8,7 +9,9 @@ export const Route = createRootRoute({
|
|||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<TooltipProvider>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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"
|
||||||
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
|
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
|
||||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
@@ -31,10 +32,11 @@ function groupByDate(items: DiaryEntryDto[]) {
|
|||||||
|
|
||||||
function DiaryPage() {
|
function DiaryPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { auth } = useAuth()
|
||||||
useDocumentTitle(t("diary.title"))
|
useDocumentTitle(t("diary.title"))
|
||||||
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: "desc" })
|
useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id })
|
||||||
const deleteReview = useDeleteReview()
|
const deleteReview = useDeleteReview()
|
||||||
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||||
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||||
import type { FeedEntryDto } from "@/lib/api/diary"
|
import type { FeedEntryDto } from "@/lib/api/diary"
|
||||||
import { SearchOverlay } from "@/components/search-overlay"
|
import { SearchOverlay } from "@/components/search-overlay"
|
||||||
@@ -69,6 +70,7 @@ function FeedTab() {
|
|||||||
useInfiniteActivityFeed({ sort_by: sortBy })
|
useInfiniteActivityFeed({ sort_by: sortBy })
|
||||||
const deleteReview = useDeleteReview()
|
const deleteReview = useDeleteReview()
|
||||||
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
||||||
|
const [detailEntry, setDetailEntry] = 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])
|
||||||
|
|
||||||
@@ -123,6 +125,7 @@ function FeedTab() {
|
|||||||
isFederated={entry.is_federated}
|
isFederated={entry.is_federated}
|
||||||
actorUrl={entry.actor_url}
|
actorUrl={entry.actor_url}
|
||||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||||
|
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return isOwn ? (
|
return isOwn ? (
|
||||||
@@ -149,6 +152,16 @@ function FeedTab() {
|
|||||||
review={editingEntry.review}
|
review={editingEntry.review}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{detailEntry && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailEntry}
|
||||||
|
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||||
|
movie={detailEntry.movie}
|
||||||
|
review={detailEntry.review}
|
||||||
|
userName={detailEntry.user_display_name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function ProfilePage() {
|
|||||||
|
|
||||||
<ProfileView
|
<ProfileView
|
||||||
data={data}
|
data={data}
|
||||||
|
userId={auth.user_id}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
Reference in New Issue
Block a user