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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user