feat: wrapup wow — animated counters, scroll-reveal, fun facts, component split, budget formatting
Some checks failed
CI / Check / Test (push) Failing after 6m25s

This commit is contained in:
2026-06-04 17:15:35 +02:00
parent ebf9a9f4a8
commit 4bd8dcbf05
8 changed files with 425 additions and 238 deletions

View File

@@ -0,0 +1,17 @@
import { useScrollReveal } from "@/hooks/use-animate"
export function RevealCard({ children }: { children: React.ReactNode }) {
const { ref, visible } = useScrollReveal()
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next"
import { Lightbulb } from "lucide-react"
import { fmtUsd } from "@/lib/format"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { RevealCard } from "@/components/reveal-card"
import type { WrapUpReport } from "@/lib/api/wrapup"
export function FunFacts({ report, watchHours }: { report: WrapUpReport; watchHours: number }) {
const { t } = useTranslation()
const facts: string[] = []
const oldest = report.oldest_movie?.year
const newest = report.newest_movie?.year
if (oldest && newest && newest - oldest > 0) {
facts.push(t("wrapup.funSpan", { span: newest - oldest, oldest, newest }))
}
if (watchHours >= 24) {
const days = (watchHours / 24).toFixed(1)
facts.push(t("wrapup.funDays", { days }))
} else if (watchHours > 0) {
facts.push(t("wrapup.funHours", { hours: watchHours }))
}
if (report.total_budget_watched && report.total_budget_watched >= 1_000_000) {
facts.push(t("wrapup.funBudget", { amount: fmtUsd(report.total_budget_watched) }))
}
if (report.genre_diversity > 5) {
facts.push(t("wrapup.funGenres", { count: report.genre_diversity }))
}
if (report.actor_diversity > 10) {
facts.push(t("wrapup.funActors", { count: report.actor_diversity }))
}
if (!facts.length) return null
return (
<RevealCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<Lightbulb className="size-4" /> {t("wrapup.funFacts")}
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{facts.map((fact, i) => (
<li key={i} className="text-sm text-muted-foreground"> {fact}</li>
))}
</ul>
</CardContent>
</Card>
</RevealCard>
)
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { Card, CardContent } from "@/components/ui/card"
import { RevealCard } from "@/components/reveal-card"
import { useCountUp } from "@/hooks/use-animate"
import type { WrapUpReport } from "@/lib/api/wrapup"
export function HeroCard({ report, watchHours }: { report: WrapUpReport; watchHours: number }) {
const { t } = useTranslation()
const movies = useCountUp(report.total_movies)
const hours = useCountUp(watchHours)
return (
<RevealCard>
<Card>
<CardContent className="py-8 text-center" ref={movies.ref}>
<p className="text-xs uppercase tracking-widest text-muted-foreground">{t("wrapup.heroSubtitle")}</p>
<p className="mt-2 text-5xl font-extrabold tracking-tight">{movies.value}</p>
<p className="text-sm text-muted-foreground">{t("wrapup.moviesWatched")}</p>
{watchHours > 0 && (
<p className="mt-1 text-xs text-muted-foreground" ref={hours.ref}>
{t("wrapup.watchHours", { hours: hours.value })}
</p>
)}
</CardContent>
</Card>
</RevealCard>
)
}

View File

@@ -0,0 +1,50 @@
import { Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { Users } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { tmdbProfileUrl } from "@/lib/api/client"
import type { PersonStat } from "@/lib/api/wrapup"
export function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) {
const { t } = useTranslation()
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="size-4" /> {title}
</CardTitle>
<CardDescription>{subtitle}</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-2">
{items.map((item, i) => {
const profilePath = profilePaths?.[i]
const inner = (
<>
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span>
<Avatar className="size-8">
{profilePath && <AvatarImage src={tmdbProfileUrl(profilePath)} />}
<AvatarFallback className="text-xs">{item.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p>
</div>
</>
)
return (
<li key={item.name}>
{item.person_id ? (
<Link to="/people/$id" params={{ id: item.person_id }} className="flex items-center gap-3">{inner}</Link>
) : (
<div className="flex items-center gap-3">{inner}</div>
)}
</li>
)
})}
</ol>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from "react"
export function useCountUp(target: number, duration = 1200) {
const [value, setValue] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const started = useRef(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !started.current) {
started.current = true
const start = performance.now()
const step = (now: number) => {
const progress = Math.min((now - start) / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
setValue(Math.round(eased * target))
if (progress < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
}
},
{ threshold: 0.3 },
)
observer.observe(el)
return () => observer.disconnect()
}, [target, duration])
return { ref, value }
}
export function useScrollReveal() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true)
observer.disconnect()
}
},
{ threshold: 0.1 },
)
observer.observe(el)
return () => observer.disconnect()
}, [])
return { ref, visible }
}

6
spa/src/lib/format.ts Normal file
View File

@@ -0,0 +1,6 @@
export function fmtUsd(n: number): string {
if (n >= 1_000_000_000) return `$${(n / 1_000_000_000).toFixed(n % 1_000_000_000 === 0 ? 0 : 1)}B`
if (n >= 1_000_000) return `$${Math.round(n / 1_000_000)}M`
if (n >= 1_000) return `$${Math.round(n / 1_000)}K`
return `$${n}`
}

View File

@@ -259,6 +259,13 @@
"topDirectorLabel": "Top Director", "topDirectorLabel": "Top Director",
"topActorLabel": "Top Actor", "topActorLabel": "Top Actor",
"busiestMonthLabel": "Busiest Month", "busiestMonthLabel": "Busiest Month",
"funFacts": "Fun Facts",
"funSpan": "Your movies span {{span}} years ({{oldest}}{{newest}})",
"funDays": "{{days}} days of non-stop watch time",
"funHours": "{{hours}} hours of watch time",
"funBudget": "You watched {{amount}} worth of movies",
"funGenres": "You explored {{count}} different genres",
"funActors": "You saw {{count}} different actors",
"allMovies": "All Movies ({{count}})" "allMovies": "All Movies ({{count}})"
}, },
"logReview": { "logReview": {

View File

@@ -2,10 +2,24 @@ import { createFileRoute, Link } from "@tanstack/react-router"
import { lazy, Suspense, useState } from "react" import { lazy, Suspense, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Bar, BarChart, XAxis, YAxis } from "recharts" import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { BarChart3, DollarSign, Globe, Hash, Share2, Star, Users } from "lucide-react" import { BarChart3, DollarSign, Globe, Hash, Share2, Star } from "lucide-react"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { BackButton } from "@/components/back-button" import { BackButton } from "@/components/back-button"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { RatingHistogram } from "@/components/rating-histogram"
import { RevealCard } from "@/components/reveal-card"
import { HeroCard } from "@/components/wrapup-hero"
import { FunFacts } from "@/components/wrapup-fun-facts"
import { RankCard } from "@/components/wrapup-rank-card"
import { posterUrl } from "@/lib/api/client"
import { fmtUsd } from "@/lib/format"
import { useWrapUpReport } from "@/hooks/use-wrapup"
import type { MovieRef } from "@/lib/api/wrapup"
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
const monthlyChartConfig = { const monthlyChartConfig = {
count: { label: "Movies", color: "var(--primary)" }, count: { label: "Movies", color: "var(--primary)" },
@@ -15,16 +29,6 @@ const genreChartConfig = {
count: { label: "Movies", color: "var(--primary)" }, count: { label: "Movies", color: "var(--primary)" },
} satisfies ChartConfig } satisfies ChartConfig
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { RatingHistogram } from "@/components/rating-histogram"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useWrapUpReport } from "@/hooks/use-wrapup"
import type { MovieRef, PersonStat } from "@/lib/api/wrapup"
export const Route = createFileRoute("/_app/wrapup/$id")({ export const Route = createFileRoute("/_app/wrapup/$id")({
component: WrapUpReportPage, component: WrapUpReportPage,
}) })
@@ -33,7 +37,6 @@ function WrapUpReportPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = Route.useParams() const { id } = Route.useParams()
const { data: report, isPending } = useWrapUpReport(id) const { data: report, isPending } = useWrapUpReport(id)
const [showShare, setShowShare] = useState(false) const [showShare, setShowShare] = useState(false)
if (isPending) return <ReportSkeleton /> if (isPending) return <ReportSkeleton />
@@ -56,133 +59,135 @@ function WrapUpReportPage() {
</Suspense> </Suspense>
)} )}
{/* Hero */} <HeroCard report={report} watchHours={watchHours} />
<Card>
<CardContent className="py-8 text-center">
<p className="text-xs uppercase tracking-widest text-muted-foreground">{t("wrapup.heroSubtitle")}</p>
<p className="mt-2 text-5xl font-extrabold tracking-tight">{report.total_movies}</p>
<p className="text-sm text-muted-foreground">{t("wrapup.moviesWatched")}</p>
{watchHours > 0 && (
<p className="mt-1 text-xs text-muted-foreground">{t("wrapup.watchHours", { hours: watchHours })}</p>
)}
</CardContent>
</Card>
{/* Ratings */} {/* Ratings */}
<Card> <RevealCard>
<CardHeader> <Card>
<CardTitle className="flex items-center gap-2 text-sm"> <CardHeader>
<Star className="size-4" /> {t("wrapup.ratings")} <CardTitle className="flex items-center gap-2 text-sm">
</CardTitle> <Star className="size-4" /> {t("wrapup.ratings")}
</CardHeader> </CardTitle>
<CardContent className="space-y-4"> </CardHeader>
{report.avg_rating != null && ( <CardContent className="space-y-4">
<div className="text-center"> {report.avg_rating != null && (
<p className="text-4xl font-bold text-amber-500">{report.avg_rating.toFixed(1)}</p> <div className="text-center">
<p className="text-xs text-muted-foreground">{t("wrapup.averageRating")}</p> <p className="text-4xl font-bold text-amber-500">{report.avg_rating.toFixed(1)}</p>
<p className="text-xs text-muted-foreground">{t("wrapup.averageRating")}</p>
</div>
)}
<RatingHistogram histogram={report.rating_distribution} />
<div className="flex flex-wrap gap-2">
{report.busiest_month && (
<Badge variant="secondary">{t("wrapup.busiestMonth", { month: report.busiest_month })}</Badge>
)}
{report.busiest_day_of_week && (
<Badge variant="secondary">{t("wrapup.favoriteDay", { day: report.busiest_day_of_week })}</Badge>
)}
</div> </div>
)} </CardContent>
<RatingHistogram histogram={report.rating_distribution} /> </Card>
<div className="flex flex-wrap gap-2"> </RevealCard>
{report.busiest_month && (
<Badge variant="secondary">{t("wrapup.busiestMonth", { month: report.busiest_month })}</Badge>
)}
{report.busiest_day_of_week && (
<Badge variant="secondary">{t("wrapup.favoriteDay", { day: report.busiest_day_of_week })}</Badge>
)}
</div>
</CardContent>
</Card>
{/* Top Directors */} {/* Top Directors */}
{report.top_directors.length > 0 && ( {report.top_directors.length > 0 && (
<RankCard <RevealCard>
title={t("wrapup.topDirectors")} <RankCard
subtitle={t("wrapup.uniqueDirectors", { count: report.director_diversity })} title={t("wrapup.topDirectors")}
items={report.top_directors.slice(0, 5)} subtitle={t("wrapup.uniqueDirectors", { count: report.director_diversity })}
/> items={report.top_directors.slice(0, 5)}
/>
</RevealCard>
)} )}
{/* Top Actors */} {/* Top Actors */}
{report.top_actors.length > 0 && ( {report.top_actors.length > 0 && (
<RankCard <RevealCard>
title={t("wrapup.topActors")} <RankCard
subtitle={t("wrapup.uniqueActors", { count: report.actor_diversity })} title={t("wrapup.topActors")}
items={report.top_actors.slice(0, 5)} subtitle={t("wrapup.uniqueActors", { count: report.actor_diversity })}
profilePaths={report.top_cast_profile_paths} items={report.top_actors.slice(0, 5)}
/> profilePaths={report.top_cast_profile_paths}
/>
</RevealCard>
)} )}
{/* Genres */} {/* Genres */}
{report.top_genres.length > 0 && ( {report.top_genres.length > 0 && (
<Card> <RevealCard>
<CardHeader> <Card>
<CardTitle className="text-sm">{t("wrapup.genres")}</CardTitle> <CardHeader>
<CardDescription>{t("wrapup.genresExplored", { count: report.genre_diversity })}</CardDescription> <CardTitle className="text-sm">{t("wrapup.genres")}</CardTitle>
</CardHeader> <CardDescription>{t("wrapup.genresExplored", { count: report.genre_diversity })}</CardDescription>
<CardContent className="space-y-2"> </CardHeader>
<ChartContainer config={genreChartConfig} className="w-full" style={{ height: Math.min(report.top_genres.length, 8) * 28 + 16 }}> <CardContent className="space-y-2">
<BarChart data={report.top_genres.slice(0, 8)} layout="vertical" margin={{ top: 0, right: 4, bottom: 0, left: 0 }}> <ChartContainer config={genreChartConfig} className="w-full" style={{ height: Math.min(report.top_genres.length, 8) * 28 + 16 }}>
<XAxis type="number" hide /> <BarChart data={report.top_genres.slice(0, 8)} layout="vertical" margin={{ top: 0, right: 4, bottom: 0, left: 0 }}>
<YAxis type="category" dataKey="genre" tick={{ fontSize: 11, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} width={80} /> <XAxis type="number" hide />
<ChartTooltip content={<ChartTooltipContent />} /> <YAxis type="category" dataKey="genre" tick={{ fontSize: 11, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} width={80} />
<Bar dataKey="count" fill="var(--color-count)" radius={[0, 4, 4, 0]} /> <ChartTooltip content={<ChartTooltipContent />} />
</BarChart> <Bar dataKey="count" fill="var(--color-count)" radius={[0, 4, 4, 0]} />
</ChartContainer> </BarChart>
<div className="flex flex-wrap gap-2 pt-2"> </ChartContainer>
{report.highest_rated_genre && ( <div className="flex flex-wrap gap-2 pt-2">
<Badge variant="secondary">{t("wrapup.highestRated", { genre: report.highest_rated_genre })}</Badge> {report.highest_rated_genre && (
)} <Badge variant="secondary">{t("wrapup.highestRated", { genre: report.highest_rated_genre })}</Badge>
{report.lowest_rated_genre && ( )}
<Badge variant="secondary">{t("wrapup.lowestRated", { genre: report.lowest_rated_genre })}</Badge> {report.lowest_rated_genre && (
)} <Badge variant="secondary">{t("wrapup.lowestRated", { genre: report.lowest_rated_genre })}</Badge>
</div> )}
</CardContent> </div>
</Card> </CardContent>
</Card>
</RevealCard>
)} )}
{/* Monthly Activity */} {/* Monthly Activity */}
{report.movies_per_month.length > 0 && ( {report.movies_per_month.length > 0 && (
<Card> <RevealCard>
<CardHeader> <Card>
<CardTitle className="flex items-center gap-2 text-sm"> <CardHeader>
<BarChart3 className="size-4" /> {t("wrapup.monthlyActivity")} <CardTitle className="flex items-center gap-2 text-sm">
</CardTitle> <BarChart3 className="size-4" /> {t("wrapup.monthlyActivity")}
</CardHeader> </CardTitle>
<CardContent> </CardHeader>
<ChartContainer config={monthlyChartConfig} className="aspect-[2/1] w-full"> <CardContent>
<BarChart data={report.movies_per_month} margin={{ top: 8, right: 0, bottom: 0, left: -20 }}> <ChartContainer config={monthlyChartConfig} className="aspect-[2/1] w-full">
<XAxis dataKey="year_month" tickFormatter={(v: string) => v.slice(5)} tick={{ fontSize: 10, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} /> <BarChart data={report.movies_per_month} margin={{ top: 8, right: 0, bottom: 0, left: -20 }}>
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} width={30} /> <XAxis dataKey="year_month" tickFormatter={(v: string) => v.slice(5)} tick={{ fontSize: 10, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => report.movies_per_month.find((m) => m.year_month === String(v))?.label ?? String(v)} />} /> <YAxis allowDecimals={false} tick={{ fontSize: 10, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} width={30} />
<Bar dataKey="count" fill="var(--color-count)" radius={[4, 4, 0, 0]} /> <ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => report.movies_per_month.find((m) => m.year_month === String(v))?.label ?? String(v)} />} />
</BarChart> <Bar dataKey="count" fill="var(--color-count)" radius={[4, 4, 0, 0]} />
</ChartContainer> </BarChart>
</CardContent> </ChartContainer>
</Card> </CardContent>
</Card>
</RevealCard>
)} )}
{/* Keywords */} {/* Keywords */}
{report.top_keywords.length > 0 && ( {report.top_keywords.length > 0 && (
<Card> <RevealCard>
<CardHeader> <Card>
<CardTitle className="flex items-center gap-2 text-sm"> <CardHeader>
<Hash className="size-4" /> {t("wrapup.keywords")} <CardTitle className="flex items-center gap-2 text-sm">
</CardTitle> <Hash className="size-4" /> {t("wrapup.keywords")}
</CardHeader> </CardTitle>
<CardContent> </CardHeader>
<div className="flex flex-wrap gap-1.5"> <CardContent>
{report.top_keywords <div className="flex flex-wrap gap-1.5">
.filter((k) => !k.keyword.includes("creditsstinger")) {report.top_keywords
.slice(0, 15) .filter((k) => !k.keyword.includes("creditsstinger"))
.map((k) => ( .slice(0, 15)
<Badge key={k.keyword} variant="secondary" className="text-xs"> .map((k) => (
{k.keyword} <span className="ml-1 opacity-60">{k.count}</span> <Badge key={k.keyword} variant="secondary" className="text-xs">
</Badge> {k.keyword} <span className="ml-1 opacity-60">{k.count}</span>
))} </Badge>
</div> ))}
</CardContent> </div>
</Card> </CardContent>
</Card>
</RevealCard>
)} )}
{/* Budget & Language */} {/* Budget & Language */}
@@ -191,144 +196,104 @@ function WrapUpReportPage() {
const hasLang = report.language_distribution.length > 1 const hasLang = report.language_distribution.length > 1
const bothVisible = hasBudget && hasLang const bothVisible = hasBudget && hasLang
return ( return (
<div className={bothVisible ? "grid grid-cols-2 gap-3" : ""}> <RevealCard>
{hasBudget && ( <div className={bothVisible ? "grid grid-cols-2 gap-3" : ""}>
<Card> {hasBudget && (
<CardContent className="py-4 text-center"> <Card>
<DollarSign className="mx-auto mb-1 size-4 text-muted-foreground" /> <CardContent className="py-4 text-center">
<p className="text-lg font-bold">${Math.round(report.total_budget_watched! / 1_000_000)}M</p> <DollarSign className="mx-auto mb-1 size-4 text-muted-foreground" />
<p className="text-[10px] text-muted-foreground">{t("wrapup.totalBudget")}</p> <p className="text-lg font-bold">{fmtUsd(report.total_budget_watched!)}</p>
{report.avg_budget != null && ( <p className="text-[10px] text-muted-foreground">{t("wrapup.totalBudget")}</p>
{report.avg_budget != null && (
<p className="mt-1 text-[10px] text-muted-foreground">
{t("wrapup.avgBudget", { amount: fmtUsd(report.avg_budget) })}
</p>
)}
</CardContent>
</Card>
)}
{hasLang && (
<Card>
<CardContent className="py-4 text-center">
<Globe className="mx-auto mb-1 size-4 text-muted-foreground" />
<p className="text-lg font-bold">{report.language_distribution.length}</p>
<p className="text-[10px] text-muted-foreground">{t("wrapup.languages")}</p>
<p className="mt-1 text-[10px] text-muted-foreground"> <p className="mt-1 text-[10px] text-muted-foreground">
{t("wrapup.avgBudget", { amount: `$${Math.round(report.avg_budget / 1_000_000)}M` })} {report.language_distribution.slice(0, 3).map((l) => l.language.toUpperCase()).join(", ")}
</p> </p>
)} </CardContent>
</CardContent> </Card>
</Card> )}
)} </div>
{hasLang && ( </RevealCard>
<Card>
<CardContent className="py-4 text-center">
<Globe className="mx-auto mb-1 size-4 text-muted-foreground" />
<p className="text-lg font-bold">{report.language_distribution.length}</p>
<p className="text-[10px] text-muted-foreground">{t("wrapup.languages")}</p>
<p className="mt-1 text-[10px] text-muted-foreground">
{report.language_distribution.slice(0, 3).map((l) => l.language.toUpperCase()).join(", ")}
</p>
</CardContent>
</Card>
)}
</div>
)})()} )})()}
{/* Highlights */} {/* Highlights */}
<Card> <RevealCard>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.highlights")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<MovieHighlight label={t("wrapup.highlightHighest")} movie={report.highest_rated_movie} />
<MovieHighlight label={t("wrapup.highlightLowest")} movie={report.lowest_rated_movie} />
<MovieHighlight label={t("wrapup.highlightOldest")} movie={report.oldest_movie} />
<MovieHighlight label={t("wrapup.highlightNewest")} movie={report.newest_movie} />
<MovieHighlight label={t("wrapup.highlightLongest")} movie={report.longest_movie} showRuntime />
<MovieHighlight label={t("wrapup.highlightShortest")} movie={report.shortest_movie} showRuntime />
<MovieHighlight label={t("wrapup.highlightFirst")} movie={report.first_movie_of_period} />
<MovieHighlight label={t("wrapup.highlightLast")} movie={report.last_movie_of_period} />
</div>
</CardContent>
</Card>
{/* Rewatches */}
{report.total_rewatches > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm">{t("wrapup.rewatches")}</CardTitle> <CardTitle className="text-sm">{t("wrapup.highlights")}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-3xl font-bold">{report.total_rewatches}</p>
<p className="text-xs text-muted-foreground">{t("wrapup.moviesRewatched")}</p>
{report.most_rewatched_movie && (
<p className="mt-2 text-sm text-muted-foreground">
{t("wrapup.mostRewatched")} <strong>{report.most_rewatched_movie.title}</strong> ({report.most_rewatched_movie.year})
</p>
)}
</CardContent>
</Card>
)}
{/* Poster Mosaic */}
{report.poster_paths.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.allMovies", { count: report.poster_paths.length })}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-5 gap-1"> <div className="grid grid-cols-2 gap-3">
{report.poster_paths.map((path, i) => ( <MovieHighlight label={t("wrapup.highlightHighest")} movie={report.highest_rated_movie} />
<div key={i} className="aspect-[2/3] overflow-hidden rounded-md bg-muted"> <MovieHighlight label={t("wrapup.highlightLowest")} movie={report.lowest_rated_movie} />
<img src={posterUrl(path)} alt="" className="size-full object-cover" loading="lazy" /> <MovieHighlight label={t("wrapup.highlightOldest")} movie={report.oldest_movie} />
</div> <MovieHighlight label={t("wrapup.highlightNewest")} movie={report.newest_movie} />
))} <MovieHighlight label={t("wrapup.highlightLongest")} movie={report.longest_movie} showRuntime />
<MovieHighlight label={t("wrapup.highlightShortest")} movie={report.shortest_movie} showRuntime />
<MovieHighlight label={t("wrapup.highlightFirst")} movie={report.first_movie_of_period} />
<MovieHighlight label={t("wrapup.highlightLast")} movie={report.last_movie_of_period} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</RevealCard>
<FunFacts report={report} watchHours={watchHours} />
{/* Rewatches */}
{report.total_rewatches > 0 && (
<RevealCard>
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.rewatches")}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-3xl font-bold">{report.total_rewatches}</p>
<p className="text-xs text-muted-foreground">{t("wrapup.moviesRewatched")}</p>
{report.most_rewatched_movie && (
<p className="mt-2 text-sm text-muted-foreground">
{t("wrapup.mostRewatched")} <strong>{report.most_rewatched_movie.title}</strong> ({report.most_rewatched_movie.year})
</p>
)}
</CardContent>
</Card>
</RevealCard>
)}
{/* All Movies */}
{report.poster_paths.length > 0 && (
<RevealCard>
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.allMovies", { count: report.poster_paths.length })}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-5 gap-1">
{report.poster_paths.map((path, i) => (
<div key={i} className="aspect-[2/3] overflow-hidden rounded-md bg-muted">
<img src={posterUrl(path)} alt="" className="size-full object-cover" loading="lazy" />
</div>
))}
</div>
</CardContent>
</Card>
</RevealCard>
)} )}
</div> </div>
) )
} }
function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) {
const { t } = useTranslation()
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="size-4" /> {title}
</CardTitle>
<CardDescription>{subtitle}</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-2">
{items.map((item, i) => {
const profilePath = profilePaths?.[i]
return (
<li key={item.name}>
{item.person_id ? (
<Link to="/people/$id" params={{ id: item.person_id }} className="flex items-center gap-3">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span>
<Avatar className="size-8">
{profilePath && <AvatarImage src={tmdbProfileUrl(profilePath)} />}
<AvatarFallback className="text-xs">{item.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p>
</div>
</Link>
) : (
<div className="flex items-center gap-3">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span>
<Avatar className="size-8">
{profilePath && <AvatarImage src={tmdbProfileUrl(profilePath)} />}
<AvatarFallback className="text-xs">{item.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p>
</div>
</div>
)}
</li>
)
})}
</ol>
</CardContent>
</Card>
)
}
function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?: MovieRef; showRuntime?: boolean }) { function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?: MovieRef; showRuntime?: boolean }) {
if (!movie) return null if (!movie) return null
const content = ( const content = (