diff --git a/spa/src/components/reveal-card.tsx b/spa/src/components/reveal-card.tsx new file mode 100644 index 0000000..e6fa2df --- /dev/null +++ b/spa/src/components/reveal-card.tsx @@ -0,0 +1,17 @@ +import { useScrollReveal } from "@/hooks/use-animate" + +export function RevealCard({ children }: { children: React.ReactNode }) { + const { ref, visible } = useScrollReveal() + return ( +
+ {children} +
+ ) +} diff --git a/spa/src/components/wrapup-fun-facts.tsx b/spa/src/components/wrapup-fun-facts.tsx new file mode 100644 index 0000000..a776a48 --- /dev/null +++ b/spa/src/components/wrapup-fun-facts.tsx @@ -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 ( + + + + + {t("wrapup.funFacts")} + + + +
    + {facts.map((fact, i) => ( +
  • ✦ {fact}
  • + ))} +
+
+
+
+ ) +} diff --git a/spa/src/components/wrapup-hero.tsx b/spa/src/components/wrapup-hero.tsx new file mode 100644 index 0000000..552572b --- /dev/null +++ b/spa/src/components/wrapup-hero.tsx @@ -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 ( + + + +

{t("wrapup.heroSubtitle")}

+

{movies.value}

+

{t("wrapup.moviesWatched")}

+ {watchHours > 0 && ( +

+ {t("wrapup.watchHours", { hours: hours.value })} +

+ )} +
+
+
+ ) +} diff --git a/spa/src/components/wrapup-rank-card.tsx b/spa/src/components/wrapup-rank-card.tsx new file mode 100644 index 0000000..b168eab --- /dev/null +++ b/spa/src/components/wrapup-rank-card.tsx @@ -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 ( + + + + {title} + + {subtitle} + + +
    + {items.map((item, i) => { + const profilePath = profilePaths?.[i] + const inner = ( + <> + {i + 1} + + {profilePath && } + {item.name[0]} + +
    +

    {item.name}

    +

    {t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}★

    +
    + + ) + return ( +
  1. + {item.person_id ? ( + {inner} + ) : ( +
    {inner}
    + )} +
  2. + ) + })} +
+
+
+ ) +} diff --git a/spa/src/hooks/use-animate.ts b/spa/src/hooks/use-animate.ts new file mode 100644 index 0000000..059ecca --- /dev/null +++ b/spa/src/hooks/use-animate.ts @@ -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(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(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 } +} diff --git a/spa/src/lib/format.ts b/spa/src/lib/format.ts new file mode 100644 index 0000000..7de454f --- /dev/null +++ b/spa/src/lib/format.ts @@ -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}` +} diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index c8c9a37..4320270 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -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": { diff --git a/spa/src/routes/_app/wrapup.$id.tsx b/spa/src/routes/_app/wrapup.$id.tsx index 3bde4ce..6ff8a8b 100644 --- a/spa/src/routes/_app/wrapup.$id.tsx +++ b/spa/src/routes/_app/wrapup.$id.tsx @@ -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 @@ -56,133 +59,135 @@ function WrapUpReportPage() { )} - {/* Hero */} - - -

{t("wrapup.heroSubtitle")}

-

{report.total_movies}

-

{t("wrapup.moviesWatched")}

- {watchHours > 0 && ( -

{t("wrapup.watchHours", { hours: watchHours })}

- )} -
-
+ {/* Ratings */} - - - - {t("wrapup.ratings")} - - - - {report.avg_rating != null && ( -
-

{report.avg_rating.toFixed(1)}★

-

{t("wrapup.averageRating")}

+ + + + + {t("wrapup.ratings")} + + + + {report.avg_rating != null && ( +
+

{report.avg_rating.toFixed(1)}★

+

{t("wrapup.averageRating")}

+
+ )} + +
+ {report.busiest_month && ( + {t("wrapup.busiestMonth", { month: report.busiest_month })} + )} + {report.busiest_day_of_week && ( + {t("wrapup.favoriteDay", { day: report.busiest_day_of_week })} + )}
- )} - -
- {report.busiest_month && ( - {t("wrapup.busiestMonth", { month: report.busiest_month })} - )} - {report.busiest_day_of_week && ( - {t("wrapup.favoriteDay", { day: report.busiest_day_of_week })} - )} -
-
-
+ + +
{/* Top Directors */} {report.top_directors.length > 0 && ( - + + + )} {/* Top Actors */} {report.top_actors.length > 0 && ( - + + + )} {/* Genres */} {report.top_genres.length > 0 && ( - - - {t("wrapup.genres")} - {t("wrapup.genresExplored", { count: report.genre_diversity })} - - - - - - - } /> - - - -
- {report.highest_rated_genre && ( - {t("wrapup.highestRated", { genre: report.highest_rated_genre })} - )} - {report.lowest_rated_genre && ( - {t("wrapup.lowestRated", { genre: report.lowest_rated_genre })} - )} -
-
-
+ + + + {t("wrapup.genres")} + {t("wrapup.genresExplored", { count: report.genre_diversity })} + + + + + + + } /> + + + +
+ {report.highest_rated_genre && ( + {t("wrapup.highestRated", { genre: report.highest_rated_genre })} + )} + {report.lowest_rated_genre && ( + {t("wrapup.lowestRated", { genre: report.lowest_rated_genre })} + )} +
+
+
+
)} {/* Monthly Activity */} {report.movies_per_month.length > 0 && ( - - - - {t("wrapup.monthlyActivity")} - - - - - - v.slice(5)} tick={{ fontSize: 10, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} /> - - report.movies_per_month.find((m) => m.year_month === String(v))?.label ?? String(v)} />} /> - - - - - + + + + + {t("wrapup.monthlyActivity")} + + + + + + v.slice(5)} tick={{ fontSize: 10, fill: "rgba(255,255,255,0.85)" }} tickLine={false} axisLine={false} /> + + report.movies_per_month.find((m) => m.year_month === String(v))?.label ?? String(v)} />} /> + + + + + + )} {/* Keywords */} {report.top_keywords.length > 0 && ( - - - - {t("wrapup.keywords")} - - - -
- {report.top_keywords - .filter((k) => !k.keyword.includes("creditsstinger")) - .slice(0, 15) - .map((k) => ( - - {k.keyword} {k.count} - - ))} -
-
-
+ + + + + {t("wrapup.keywords")} + + + +
+ {report.top_keywords + .filter((k) => !k.keyword.includes("creditsstinger")) + .slice(0, 15) + .map((k) => ( + + {k.keyword} {k.count} + + ))} +
+
+
+
)} {/* Budget & Language */} @@ -191,144 +196,104 @@ function WrapUpReportPage() { const hasLang = report.language_distribution.length > 1 const bothVisible = hasBudget && hasLang return ( -
- {hasBudget && ( - - - -

${Math.round(report.total_budget_watched! / 1_000_000)}M

-

{t("wrapup.totalBudget")}

- {report.avg_budget != null && ( + +
+ {hasBudget && ( + + + +

{fmtUsd(report.total_budget_watched!)}

+

{t("wrapup.totalBudget")}

+ {report.avg_budget != null && ( +

+ {t("wrapup.avgBudget", { amount: fmtUsd(report.avg_budget) })} +

+ )} +
+
+ )} + {hasLang && ( + + + +

{report.language_distribution.length}

+

{t("wrapup.languages")}

- {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(", ")}

- )} -
-
- )} - {hasLang && ( - - - -

{report.language_distribution.length}

-

{t("wrapup.languages")}

-

- {report.language_distribution.slice(0, 3).map((l) => l.language.toUpperCase()).join(", ")} -

-
-
- )} -
+
+
+ )} +
+ )})()} {/* Highlights */} - - - {t("wrapup.highlights")} - - -
- - - - - - - - -
-
-
- - {/* Rewatches */} - {report.total_rewatches > 0 && ( + - {t("wrapup.rewatches")} - - -

{report.total_rewatches}

-

{t("wrapup.moviesRewatched")}

- {report.most_rewatched_movie && ( -

- {t("wrapup.mostRewatched")} {report.most_rewatched_movie.title} ({report.most_rewatched_movie.year}) -

- )} -
-
- )} - - {/* Poster Mosaic */} - {report.poster_paths.length > 0 && ( - - - {t("wrapup.allMovies", { count: report.poster_paths.length })} + {t("wrapup.highlights")} -
- {report.poster_paths.map((path, i) => ( -
- -
- ))} +
+ + + + + + + +
+ + + + + {/* Rewatches */} + {report.total_rewatches > 0 && ( + + + + {t("wrapup.rewatches")} + + +

{report.total_rewatches}

+

{t("wrapup.moviesRewatched")}

+ {report.most_rewatched_movie && ( +

+ {t("wrapup.mostRewatched")} {report.most_rewatched_movie.title} ({report.most_rewatched_movie.year}) +

+ )} +
+
+
+ )} + + {/* All Movies */} + {report.poster_paths.length > 0 && ( + + + + {t("wrapup.allMovies", { count: report.poster_paths.length })} + + +
+ {report.poster_paths.map((path, i) => ( +
+ +
+ ))} +
+
+
+
)}
) } -function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) { - const { t } = useTranslation() - return ( - - - - {title} - - {subtitle} - - -
    - {items.map((item, i) => { - const profilePath = profilePaths?.[i] - return ( -
  1. - {item.person_id ? ( - - {i + 1} - - {profilePath && } - {item.name[0]} - -
    -

    {item.name}

    -

    {t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}★

    -
    - - ) : ( -
    - {i + 1} - - {profilePath && } - {item.name[0]} - -
    -

    {item.name}

    -

    {t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}★

    -
    -
    - )} -
  2. - ) - })} -
-
-
- ) -} - function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?: MovieRef; showRuntime?: boolean }) { if (!movie) return null const content = (