Files
movies-diary/spa/src/routes/_app/movies.$id.tsx
Gabriel Kaszewski acc20d2f43 feat: dynamic page titles across SPA
useDocumentTitle hook sets document.title per page.
Dynamic: movie name, person name, username, wrapup year.
Static: diary, profile, search, social, all settings pages.
2026-06-11 12:45:01 +02:00

282 lines
11 KiB
TypeScript

import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
import { BackButton } from "@/components/back-button"
import { StarDisplay } from "@/components/star-display"
import { RatingHistogram } from "@/components/rating-histogram"
import { EmptyState } from "@/components/empty-state"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { timeAgo, shortDate } from "@/lib/date"
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
import { useDocumentTitle } from "@/hooks/use-document-title"
import {
useWatchlistStatus,
useAddToWatchlist,
useRemoveFromWatchlist,
} from "@/hooks/use-watchlist"
import type { CastMemberDto, CrewMemberDto } from "@/lib/api/movies"
export const Route = createFileRoute("/_app/movies/$id")({
component: MovieDetailPage,
})
function MovieDetailPage() {
const { t } = useTranslation()
const { id } = Route.useParams()
const { data, isPending } = useMovie(id)
const { data: profile } = useMovieProfile(id)
const { data: history } = useMovieHistory(id)
if (isPending) return <DetailSkeleton />
if (!data) return null
const { movie, stats, reviews } = data
useDocumentTitle(movie.title)
const hasStats = profile && (profile.budget_usd != null || profile.revenue_usd != null || profile.vote_average != null)
return (
<div className="space-y-5 p-4">
<BackButton />
<HeroSection movie={movie} stats={stats} movieId={id} tagline={profile?.tagline} />
{(profile?.overview ?? movie.overview) && (
<p className="text-sm leading-relaxed text-muted-foreground">{profile?.overview ?? movie.overview}</p>
)}
{hasStats && (
<div className="flex gap-2">
{profile.budget_usd != null && (
<div className="flex-1 rounded-xl bg-card p-2.5 text-center">
<p className="text-sm font-semibold">${(profile.budget_usd / 1e6).toFixed(0)}M</p>
<p className="text-[10px] text-muted-foreground">{t("movie.budget")}</p>
</div>
)}
{profile.revenue_usd != null && (
<div className="flex-1 rounded-xl bg-card p-2.5 text-center">
<p className="text-sm font-semibold">${(profile.revenue_usd / 1e6).toFixed(0)}M</p>
<p className="text-[10px] text-muted-foreground">{t("movie.revenue")}</p>
</div>
)}
{profile.vote_average != null && (
<div className="flex-1 rounded-xl bg-card p-2.5 text-center">
<p className="text-sm font-semibold">{profile.vote_average.toFixed(1)}</p>
<p className="text-[10px] text-muted-foreground">{t("movie.tmdb")}</p>
</div>
)}
</div>
)}
{stats.rating_histogram.length > 0 && (
<div className="rounded-xl bg-card p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("movie.ratingDistribution")}</p>
<RatingHistogram histogram={stats.rating_histogram} />
</div>
)}
{profile && profile.cast.length > 0 && (
<section className="overflow-hidden">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.cast")}</h3>
<PersonStrip items={profile.cast} type="cast" />
</section>
)}
{profile && profile.crew.length > 0 && (
<section className="overflow-hidden">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.crew")}</h3>
<PersonStrip items={profile.crew} type="crew" />
</section>
)}
{profile && profile.keywords.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.keywords")}</h3>
<div className="flex flex-wrap gap-1.5">
{profile.keywords.map((k) => (
<Badge key={k.tmdb_id} variant="outline">{k.name}</Badge>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.community")}</h3>
{!reviews.items.length ? (
<EmptyState icon={Users} title={t("movie.noReviews")} description={t("movie.beFirst")} />
) : (
<div className="space-y-2">
{reviews.items.map((r, i) => (
<Card key={i} size="sm">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-1.5 text-sm">
{r.user_display}
{r.is_federated && <Globe className="size-3 text-muted-foreground/60" />}
</CardTitle>
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
</div>
<StarDisplay rating={r.rating} size="xs" />
</div>
</CardHeader>
{r.comment && (
<CardContent>
<p className="text-xs text-muted-foreground">{r.comment}</p>
</CardContent>
)}
</Card>
))}
</div>
)}
</section>
{history && history.viewings.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.yourHistory")}</h3>
<div className="space-y-2">
{history.trend && (
<div className="flex items-center gap-2 rounded-xl bg-card p-3 text-xs text-muted-foreground">
<TrendingUp className="size-3.5" />
{t("movie.trend", { trend: history.trend })}
</div>
)}
{history.viewings.map((v) => (
<div key={v.id} className="flex items-center justify-between rounded-xl bg-card p-3">
<div>
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
{v.comment && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
)}
</div>
<StarDisplay rating={v.rating} size="xs" />
</div>
))}
</div>
</section>
)}
</div>
)
}
function HeroSection({
movie,
stats,
movieId,
tagline,
}: {
movie: { title: string; release_year: number; director?: string; poster_path?: string; genres: string[]; runtime_minutes?: number }
stats: { total_count: number; avg_rating?: number; federated_count: number }
movieId: string
tagline?: string
}) {
const { t } = useTranslation()
const { data: watchlistData } = useWatchlistStatus(movieId)
const addWatchlist = useAddToWatchlist()
const removeWatchlist = useRemoveFromWatchlist()
const onWatchlist = watchlistData?.on_watchlist ?? false
return (
<div className="flex gap-4">
<div className="h-[150px] w-[100px] flex-shrink-0 overflow-hidden rounded-xl bg-muted">
{movie.poster_path && (
<img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<h1 className="text-xl font-bold leading-tight">{movie.title}</h1>
<p className="text-sm text-muted-foreground">
{movie.release_year}
{movie.director && ` · ${movie.director}`}
{movie.runtime_minutes && ` · ${movie.runtime_minutes}m`}
</p>
{tagline && <p className="text-xs italic text-muted-foreground">{tagline}</p>}
{movie.genres.length > 0 && (
<div className="flex flex-wrap gap-1">
{movie.genres.map((g) => (
<Badge key={g} variant="secondary" className="text-[10px]">{g}</Badge>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{stats.avg_rating != null && (
<span className="flex items-center gap-1">
<Star className="size-3 fill-amber-500 text-amber-500" />
{stats.avg_rating.toFixed(1)}
</span>
)}
<span>{t("common.reviews", { count: stats.total_count })}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant={onWatchlist ? "secondary" : "outline"}
onClick={() =>
onWatchlist
? removeWatchlist.mutate(movieId)
: addWatchlist.mutate({ movie_id: movieId })
}
>
{onWatchlist ? <BookmarkCheck className="mr-1 size-3.5" /> : <Bookmark className="mr-1 size-3.5" />}
{onWatchlist ? t("movie.saved") : t("movie.watchlist")}
</Button>
</div>
</div>
</div>
)
}
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
return (
<div className="-mx-4 flex gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2" style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}>
{items.map((person, i) => {
const subtitle = type === "cast"
? (person as CastMemberDto).character
: (person as CrewMemberDto).job
return (
<Link key={`${person.tmdb_person_id}-${i}`} to="/people/$id" params={{ id: person.person_id }} className="w-[72px] flex-shrink-0">
<div className="aspect-[2/3] overflow-hidden rounded-lg bg-muted">
{person.profile_path ? (
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" loading="lazy" />
) : (
<div className="flex size-full items-center justify-center">
<User className="size-5 text-muted-foreground/40" />
</div>
)}
</div>
<p className="mt-1 truncate text-[11px] font-semibold leading-tight">{person.name}</p>
<p className="truncate text-[10px] italic text-muted-foreground">{subtitle}</p>
</Link>
)
})}
</div>
)
}
function DetailSkeleton() {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-5 w-16" />
<div className="flex gap-4">
<Skeleton className="h-[150px] w-[100px] rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 rounded-xl" />
))}
</div>
</div>
)
}