feat: wrapup wow — animated counters, scroll-reveal, fun facts, component split, budget formatting
Some checks failed
CI / Check / Test (push) Failing after 6m25s
Some checks failed
CI / Check / Test (push) Failing after 6m25s
This commit is contained in:
17
spa/src/components/reveal-card.tsx
Normal file
17
spa/src/components/reveal-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
spa/src/components/wrapup-fun-facts.tsx
Normal file
57
spa/src/components/wrapup-fun-facts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
spa/src/components/wrapup-hero.tsx
Normal file
28
spa/src/components/wrapup-hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
spa/src/components/wrapup-rank-card.tsx
Normal file
50
spa/src/components/wrapup-rank-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
spa/src/hooks/use-animate.ts
Normal file
57
spa/src/hooks/use-animate.ts
Normal 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
6
spa/src/lib/format.ts
Normal 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}`
|
||||
}
|
||||
@@ -259,6 +259,13 @@
|
||||
"topDirectorLabel": "Top Director",
|
||||
"topActorLabel": "Top Actor",
|
||||
"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}})"
|
||||
},
|
||||
"logReview": {
|
||||
|
||||
@@ -2,10 +2,24 @@ import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { lazy, Suspense, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
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 { BackButton } from "@/components/back-button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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 = {
|
||||
count: { label: "Movies", color: "var(--primary)" },
|
||||
@@ -15,16 +29,6 @@ const genreChartConfig = {
|
||||
count: { label: "Movies", color: "var(--primary)" },
|
||||
} 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")({
|
||||
component: WrapUpReportPage,
|
||||
})
|
||||
@@ -33,7 +37,6 @@ function WrapUpReportPage() {
|
||||
const { t } = useTranslation()
|
||||
const { id } = Route.useParams()
|
||||
const { data: report, isPending } = useWrapUpReport(id)
|
||||
|
||||
const [showShare, setShowShare] = useState(false)
|
||||
|
||||
if (isPending) return <ReportSkeleton />
|
||||
@@ -56,19 +59,10 @@ function WrapUpReportPage() {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<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>
|
||||
<HeroCard report={report} watchHours={watchHours} />
|
||||
|
||||
{/* Ratings */}
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
@@ -93,28 +87,34 @@ function WrapUpReportPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
|
||||
{/* Top Directors */}
|
||||
{report.top_directors.length > 0 && (
|
||||
<RevealCard>
|
||||
<RankCard
|
||||
title={t("wrapup.topDirectors")}
|
||||
subtitle={t("wrapup.uniqueDirectors", { count: report.director_diversity })}
|
||||
items={report.top_directors.slice(0, 5)}
|
||||
/>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Top Actors */}
|
||||
{report.top_actors.length > 0 && (
|
||||
<RevealCard>
|
||||
<RankCard
|
||||
title={t("wrapup.topActors")}
|
||||
subtitle={t("wrapup.uniqueActors", { count: report.actor_diversity })}
|
||||
items={report.top_actors.slice(0, 5)}
|
||||
profilePaths={report.top_cast_profile_paths}
|
||||
/>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Genres */}
|
||||
{report.top_genres.length > 0 && (
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("wrapup.genres")}</CardTitle>
|
||||
@@ -139,10 +139,12 @@ function WrapUpReportPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Monthly Activity */}
|
||||
{report.movies_per_month.length > 0 && (
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
@@ -160,10 +162,12 @@ function WrapUpReportPage() {
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{report.top_keywords.length > 0 && (
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
@@ -183,6 +187,7 @@ function WrapUpReportPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Budget & Language */}
|
||||
@@ -191,16 +196,17 @@ function WrapUpReportPage() {
|
||||
const hasLang = report.language_distribution.length > 1
|
||||
const bothVisible = hasBudget && hasLang
|
||||
return (
|
||||
<RevealCard>
|
||||
<div className={bothVisible ? "grid grid-cols-2 gap-3" : ""}>
|
||||
{hasBudget && (
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<DollarSign className="mx-auto mb-1 size-4 text-muted-foreground" />
|
||||
<p className="text-lg font-bold">${Math.round(report.total_budget_watched! / 1_000_000)}M</p>
|
||||
<p className="text-lg font-bold">{fmtUsd(report.total_budget_watched!)}</p>
|
||||
<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: `$${Math.round(report.avg_budget / 1_000_000)}M` })}
|
||||
{t("wrapup.avgBudget", { amount: fmtUsd(report.avg_budget) })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -219,9 +225,11 @@ function WrapUpReportPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</RevealCard>
|
||||
)})()}
|
||||
|
||||
{/* Highlights */}
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("wrapup.highlights")}</CardTitle>
|
||||
@@ -239,9 +247,13 @@ function WrapUpReportPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
|
||||
<FunFacts report={report} watchHours={watchHours} />
|
||||
|
||||
{/* Rewatches */}
|
||||
{report.total_rewatches > 0 && (
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("wrapup.rewatches")}</CardTitle>
|
||||
@@ -256,10 +268,12 @@ function WrapUpReportPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
)}
|
||||
|
||||
{/* Poster Mosaic */}
|
||||
{/* All Movies */}
|
||||
{report.poster_paths.length > 0 && (
|
||||
<RevealCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("wrapup.allMovies", { count: report.poster_paths.length })}</CardTitle>
|
||||
@@ -274,61 +288,12 @@ function WrapUpReportPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RevealCard>
|
||||
)}
|
||||
</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 }) {
|
||||
if (!movie) return null
|
||||
const content = (
|
||||
|
||||
Reference in New Issue
Block a user