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