Compare commits

...

11 Commits

Author SHA1 Message Date
26152660bb fix: show remote user's reviews on their profile (include_remote when user_id set)
Some checks failed
CI / Check / Test (push) Failing after 21m16s
2026-07-10 00:51:33 +02:00
c8f93bdd35 fix: human-readable watch medium labels in classic HTML (Media Server, Physical Media) 2026-07-10 00:44:23 +02:00
081a20ae31 fix: clippy too-many-args on update_remote_review, use RemoteReviewUpdate struct 2026-07-10 00:41:51 +02:00
cde2f5aaae feat: federate watch_medium in AP review objects
- add watch_medium field to ReviewObject (outbound + inbound)
- update_remote_review passes watch_medium to DB
- unknown values silently ignored on ingest
2026-07-10 00:38:29 +02:00
f584bcd724 fix: diary page filters by authenticated user, excludes federated reviews 2026-07-10 00:35:51 +02:00
925f74bb3d feat: auto-grow comment textarea, review detail sheet for full text
- textarea grows as you type instead of fixed 3 rows
- tap truncated comment to open detail sheet with full selectable text
- review detail sheet shows poster, rating, medium, date, full comment
2026-07-10 00:33:49 +02:00
306f4489fd fix: hoist TooltipProvider to app root, remove per-component instances 2026-07-10 00:25:04 +02:00
5d4622e046 feat: tooltip with concrete date on relative timestamps in SPA 2026-07-10 00:22:14 +02:00
2e2adef5e0 fix: profile shows only own reviews, federation display name in community
- diary API accepts user_id filter, profile page passes it
- movie social feed joins ap_remote_actors for handle resolution
- COALESCE prefers actor handle for remote reviews, email for local
2026-07-10 00:19:23 +02:00
206ad44e82 fix: auto-set WatchMedium::MediaServer on webhook watch confirms 2026-07-10 00:07:52 +02:00
8eecd06fb8 docs: update README + architecture for WatchMedium and review editing 2026-07-10 00:05:36 +02:00
30 changed files with 278 additions and 138 deletions

View File

@@ -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 05 rating
- Immutable append-only viewing ledger (tracks re-watches)
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 05 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

View File

@@ -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)"]

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<()>;
}

View File

@@ -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(())

View File

@@ -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?;
}

View File

@@ -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",

View File

@@ -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?;
}

View File

@@ -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 ?",

View File

@@ -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>

View File

@@ -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)]

View File

@@ -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

View File

@@ -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?;

View File

@@ -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;

View File

@@ -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,
}
}

View File

@@ -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!(

View File

@@ -8,7 +8,7 @@ type MovieCardProps = {
movie: MovieDto
rating?: number
comment?: string
subtitle?: string
subtitle?: React.ReactNode
variant?: "compact" | "full"
action?: React.ReactNode
}

View File

@@ -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"
/>
)}

View File

@@ -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>

View 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>
)
}

View File

@@ -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" }}
/>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -43,6 +43,7 @@ function ProfilePage() {
<ProfileView
data={data}
userId={auth.user_id}
search={search}
onSearchChange={setSearch}
actions={