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
|
||||
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating
|
||||
- Immutable append-only viewing ledger (tracks re-watches)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
@@ -14,7 +14,7 @@ graph TB
|
||||
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
||||
subgraph UseCases["Use Cases"]
|
||||
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_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
|
||||
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
|
||||
@@ -50,7 +50,7 @@ graph TB
|
||||
direction TB
|
||||
subgraph Models["Models"]
|
||||
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
||||
M_REVIEW["Review, DiaryEntry,<br/>FeedEntry"]
|
||||
M_REVIEW["Review, ReviewEdit,<br/>DiaryEntry, FeedEntry"]
|
||||
M_USER["User, UserSummary"]
|
||||
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
||||
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
||||
@@ -69,7 +69,7 @@ graph TB
|
||||
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
||||
end
|
||||
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
|
||||
|
||||
subgraph ApiTypes["api-types (0 domain deps)"]
|
||||
|
||||
@@ -22,7 +22,7 @@ pub use k_ap::{
|
||||
|
||||
pub use event_handler::ActivityPubEventHandler;
|
||||
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 user_adapter::DomainUserRepoAdapter;
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ pub struct ReviewObject {
|
||||
pub(crate) rating: u8,
|
||||
pub(crate) comment: Option<String>,
|
||||
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.
|
||||
#[serde(default)]
|
||||
pub(crate) review: bool,
|
||||
@@ -135,6 +137,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
rating: review.rating().value(),
|
||||
comment: comment_text,
|
||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||
review: true,
|
||||
attachment,
|
||||
tag,
|
||||
|
||||
@@ -3,6 +3,16 @@ use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
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]
|
||||
pub trait RemoteReviewRepository: Send + Sync {
|
||||
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 update_remote_review(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
watched_at: NaiveDateTime,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()>;
|
||||
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> 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 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 {
|
||||
id: review_id,
|
||||
@@ -132,7 +138,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
source: ReviewSource::Remote {
|
||||
actor_url: actor_url_str,
|
||||
},
|
||||
watch_medium: None,
|
||||
watch_medium,
|
||||
});
|
||||
|
||||
self.review_store
|
||||
@@ -182,14 +188,15 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
}
|
||||
|
||||
self.review_store
|
||||
.update_remote_review(
|
||||
ap_id.as_str(),
|
||||
actor_url.as_str(),
|
||||
obj.rating.min(5),
|
||||
obj.comment.as_deref(),
|
||||
obj.watched_at.naive_utc(),
|
||||
obj.poster_url.as_deref(),
|
||||
)
|
||||
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
|
||||
ap_id: ap_id.as_str(),
|
||||
actor_url: actor_url.as_str(),
|
||||
rating: obj.rating.min(5),
|
||||
comment: obj.comment.as_deref(),
|
||||
watched_at: obj.watched_at.naive_utc(),
|
||||
poster_url: obj.poster_url.as_deref(),
|
||||
watch_medium: obj.watch_medium.as_deref(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use domain::models::{Review, ReviewSource};
|
||||
@@ -71,35 +71,28 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_remote_review(
|
||||
&self,
|
||||
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);
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz
|
||||
WHERE ap_id = $4 AND remote_actor_url = $5",
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4
|
||||
WHERE ap_id = $5 AND remote_actor_url = $6",
|
||||
)
|
||||
.bind(rating as i64)
|
||||
.bind(comment)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if let Some(url) = poster_url {
|
||||
if let Some(url) = u.poster_url {
|
||||
sqlx::query(
|
||||
"UPDATE movies SET poster_path = $1
|
||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
|
||||
)
|
||||
.bind(url)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.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,
|
||||
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
|
||||
INNER JOIN movies m ON m.id = r.movie_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.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
|
||||
WHEN u.email IS NOT NULL THEN u.email
|
||||
ELSE r.user_id END AS user_email
|
||||
r.watch_medium,
|
||||
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
|
||||
INNER JOIN movies m ON m.id = r.movie_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
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use domain::models::{Review, ReviewSource};
|
||||
@@ -71,35 +71,28 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_remote_review(
|
||||
&self,
|
||||
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);
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?
|
||||
WHERE ap_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(rating as i64)
|
||||
.bind(comment)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if let Some(url) = poster_url {
|
||||
if let Some(url) = u.poster_url {
|
||||
sqlx::query(
|
||||
"UPDATE movies SET poster_path = ?
|
||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)",
|
||||
)
|
||||
.bind(url)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.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,
|
||||
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,
|
||||
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
|
||||
INNER JOIN movies m ON m.id = r.movie_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,
|
||||
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,
|
||||
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
|
||||
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
|
||||
INNER JOIN movies m ON m.id = r.movie_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 = ?
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(wm) = entry.review().watch_medium() %}
|
||||
<span class="watch-medium">{{ wm }}</span>
|
||||
<span class="watch-medium">{{ wm.label() }}</span>
|
||||
{% endif %}
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct DiaryQueryParams {
|
||||
pub offset: Option<u32>,
|
||||
pub sort_by: Option<String>,
|
||||
pub movie_id: Option<Uuid>,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||
|
||||
@@ -24,9 +24,9 @@ pub async fn execute(
|
||||
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||
page,
|
||||
movie_id,
|
||||
user_id,
|
||||
user_id: user_id.clone(),
|
||||
search: None,
|
||||
include_remote: false,
|
||||
include_remote: user_id.is_some(),
|
||||
};
|
||||
|
||||
diary.query_diary(&filter).await
|
||||
|
||||
@@ -56,7 +56,7 @@ pub async fn execute(
|
||||
rating: c.rating,
|
||||
comment: c.comment,
|
||||
watched_at: *event.watched_at(),
|
||||
watch_medium: None,
|
||||
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
||||
};
|
||||
|
||||
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 {
|
||||
type Err = DomainError;
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ pub fn to_diary_query(p: DiaryQueryParams) -> GetDiaryQuery {
|
||||
_ => SortDirection::Descending,
|
||||
}),
|
||||
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,
|
||||
offset: None,
|
||||
movie_id: None,
|
||||
user_id: None,
|
||||
};
|
||||
let query = to_diary_query(params);
|
||||
assert!(matches!(
|
||||
@@ -99,6 +100,7 @@ fn sort_by_other_string_becomes_descending() {
|
||||
limit: None,
|
||||
offset: None,
|
||||
movie_id: None,
|
||||
user_id: None,
|
||||
};
|
||||
let query = to_diary_query(params);
|
||||
assert!(matches!(
|
||||
|
||||
@@ -8,7 +8,7 @@ type MovieCardProps = {
|
||||
movie: MovieDto
|
||||
rating?: number
|
||||
comment?: string
|
||||
subtitle?: string
|
||||
subtitle?: React.ReactNode
|
||||
variant?: "compact" | "full"
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { EmptyState } from "@/components/empty-state"
|
||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { useInfiniteDiary } from "@/hooks/use-diary"
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import { TimeAgo } from "@/components/time-ago"
|
||||
import type { UserProfileResponse } from "@/lib/api/users"
|
||||
|
||||
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 { 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 filtered = search
|
||||
? items.filter((e) =>
|
||||
@@ -168,7 +168,7 @@ function DiaryTab({ sortBy, search }: { sortBy: string; userId?: string; search?
|
||||
movie={e.movie}
|
||||
rating={e.review.rating}
|
||||
comment={e.review.comment}
|
||||
subtitle={t("profile.watchedAgo", { when: timeAgo(e.review.watched_at) })}
|
||||
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||
variant="compact"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Globe, Pencil } from "lucide-react"
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import { TimeAgo } from "@/components/time-ago"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||
@@ -17,9 +17,10 @@ type ReviewCardProps = {
|
||||
isFederated?: boolean
|
||||
actorUrl?: string
|
||||
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 = (
|
||||
<Card size="sm">
|
||||
<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" />}
|
||||
<span>·</span>
|
||||
<span>{timeAgo(review.watched_at)}</span>
|
||||
<TimeAgo date={review.watched_at} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -59,7 +60,17 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
||||
<StarDisplay rating={review.rating} />
|
||||
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||
</div>
|
||||
{review.comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{review.comment}</p>}
|
||||
{review.comment && (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>
|
||||
{review.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</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 { CalendarIcon } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
@@ -38,7 +39,7 @@ export function ReviewFormFields({
|
||||
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
|
||||
</div>
|
||||
|
||||
<Textarea value={comment} onChange={(e) => onCommentChange(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
|
||||
<AutoGrowTextarea value={comment} onChange={onCommentChange} placeholder={t("logReview.commentPlaceholder")} className="mb-5" />
|
||||
|
||||
<div className="mb-5">
|
||||
<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 { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||
|
||||
@@ -22,7 +17,6 @@ export function WatchMediumBadge({ medium, className }: WatchMediumBadgeProps) {
|
||||
const Icon = entry.icon
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className={cn("size-6", className)} aria-label={t(entry.labelKey)}>
|
||||
@@ -31,6 +25,5 @@ export function WatchMediumBadge({ medium, className }: WatchMediumBadgeProps) {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t(entry.labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
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">
|
||||
{t("watchMedium.label")}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => {
|
||||
const selected = value === val
|
||||
@@ -49,7 +43,6 @@ export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const diaryQueryParamsSchema = z.object({
|
||||
offset: z.number().optional(),
|
||||
sort_by: z.string().optional(),
|
||||
movie_id: z.string().uuid().optional(),
|
||||
user_id: z.string().uuid().optional(),
|
||||
})
|
||||
export type DiaryQueryParams = z.infer<typeof diaryQueryParamsSchema>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
@@ -8,7 +9,9 @@ export const Route = createRootRoute({
|
||||
function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Outlet />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||
@@ -31,10 +32,11 @@ function groupByDate(items: DiaryEntryDto[]) {
|
||||
|
||||
function DiaryPage() {
|
||||
const { t } = useTranslation()
|
||||
const { auth } = useAuth()
|
||||
useDocumentTitle(t("diary.title"))
|
||||
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteDiary({ sort_by: "desc" })
|
||||
useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { StarRating } from "@/components/star-rating"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||
import type { FeedEntryDto } from "@/lib/api/diary"
|
||||
import { SearchOverlay } from "@/components/search-overlay"
|
||||
@@ -69,6 +70,7 @@ function FeedTab() {
|
||||
useInfiniteActivityFeed({ sort_by: sortBy })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
||||
const [detailEntry, setDetailEntry] = useState<FeedEntryDto | null>(null)
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||
|
||||
@@ -123,6 +125,7 @@ function FeedTab() {
|
||||
isFederated={entry.is_federated}
|
||||
actorUrl={entry.actor_url}
|
||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
|
||||
/>
|
||||
)
|
||||
return isOwn ? (
|
||||
@@ -149,6 +152,16 @@ function FeedTab() {
|
||||
review={editingEntry.review}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEntry && (
|
||||
<ReviewDetailSheet
|
||||
open={!!detailEntry}
|
||||
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||
movie={detailEntry.movie}
|
||||
review={detailEntry.review}
|
||||
userName={detailEntry.user_display_name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function ProfilePage() {
|
||||
|
||||
<ProfileView
|
||||
data={data}
|
||||
userId={auth.user_id}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
actions={
|
||||
|
||||
Reference in New Issue
Block a user