mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Replace motion.js entry animations with CSS to eliminate hydration delay
Motion's initial="hidden" rendered opacity:0 in server HTML, making content invisible until JS hydrated — causing a multi-second blank gap after skeleton disappearance. Replaced with CSS @keyframes stagger animation that plays immediately from SSR. Also made color extraction fire-and-forget, merged duplicate stats queries, and removed redundant dashboard loading.tsx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
@@ -12,43 +11,29 @@ import {
|
||||
type ContinueWatchingItemProps,
|
||||
} from "./continue-watching-card";
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
export function ContinueWatchingList({
|
||||
items,
|
||||
}: {
|
||||
items: ContinueWatchingItemProps[];
|
||||
}) {
|
||||
return (
|
||||
<motion.div variants={staggerContainer} initial="hidden" animate="visible">
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-4 sm:-mx-0"
|
||||
>
|
||||
<CarouselContent className="px-4 sm:px-0">
|
||||
{items.map((item) => (
|
||||
<CarouselItem key={item.title.id} className="basis-auto pl-4">
|
||||
<motion.div variants={staggerItem}>
|
||||
<ContinueWatchingCard item={item} />
|
||||
</motion.div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</motion.div>
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-4 sm:-mx-0"
|
||||
>
|
||||
<CarouselContent className="px-4 sm:px-0">
|
||||
{items.map((item, i) => (
|
||||
<CarouselItem key={item.title.id} className="basis-auto pl-4">
|
||||
<div
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<ContinueWatchingCard item={item} />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IconPlayerPlay,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -56,20 +55,12 @@ function StatCard({
|
||||
value,
|
||||
index,
|
||||
label,
|
||||
loading,
|
||||
sparklineData,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
delay: index * 0.08,
|
||||
}}
|
||||
className="relative overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4"
|
||||
<div
|
||||
className="animate-stagger-item relative overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4"
|
||||
style={{ "--stagger-index": index } as React.CSSProperties}
|
||||
>
|
||||
{sparklineData && <Sparkline data={sparklineData} color={color} />}
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
@@ -83,11 +74,12 @@ function StatCard({
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity duration-300 ${loading ? "opacity-40" : "opacity-100"}`}
|
||||
suppressHydrationWarning
|
||||
className={`relative z-10 mt-2 font-display text-2xl tabular-nums tracking-tight ${color} transition-opacity duration-300`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +123,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||
const movieStats = useAtomValue(movieStatsLoadable);
|
||||
const episodeStats = useAtomValue(episodeStatsLoadable);
|
||||
|
||||
const movieLoading = movieStats.state === "loading";
|
||||
const _movieLoading = movieStats.state === "loading";
|
||||
const movieCount =
|
||||
movieStats.state === "hasData"
|
||||
? movieStats.data.count
|
||||
@@ -139,7 +131,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||
const movieHistory =
|
||||
movieStats.state === "hasData" ? movieStats.data.history : undefined;
|
||||
|
||||
const episodeLoading = episodeStats.state === "loading";
|
||||
const _episodeLoading = episodeStats.state === "loading";
|
||||
const episodeCount =
|
||||
episodeStats.state === "hasData"
|
||||
? episodeStats.data.count
|
||||
@@ -155,7 +147,6 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||
bgColor="bg-primary/10"
|
||||
value={movieCount}
|
||||
index={0}
|
||||
loading={movieLoading}
|
||||
sparklineData={movieHistory}
|
||||
label={
|
||||
<PeriodSelector
|
||||
@@ -171,7 +162,6 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||
bgColor="bg-status-watching/10"
|
||||
value={episodeCount}
|
||||
index={1}
|
||||
loading={episodeLoading}
|
||||
sparklineData={episodeHistory}
|
||||
label={
|
||||
<PeriodSelector
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
interface TitleGridItem {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
@@ -30,14 +14,13 @@ interface TitleGridItem {
|
||||
|
||||
export function TitleGrid({ items }: { items: TitleGridItem[] }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{items.map((t) => (
|
||||
<motion.div key={t.id} variants={staggerItem}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{items.map((t, i) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<TitleCard
|
||||
id={t.id}
|
||||
tmdbId={t.tmdbId}
|
||||
@@ -47,8 +30,8 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
|
||||
releaseDate={t.releaseDate}
|
||||
voteAverage={t.voteAverage}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { DashboardSkeleton } from "@/components/skeletons";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||
import { ExploreTitleCard } from "@/components/title-card";
|
||||
@@ -46,21 +45,6 @@ interface FilterableTitleRowProps {
|
||||
episodeProgress?: Record<string, { watched: number; total: number }>;
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
export function FilterableTitleRow({
|
||||
heading,
|
||||
icon,
|
||||
@@ -178,12 +162,7 @@ function FilterableTitleRowInner({
|
||||
|
||||
{/* Carousel */}
|
||||
{!loading && items.length > 0 && (
|
||||
<motion.div
|
||||
key={selectedGenre ?? "default"}
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div key={selectedGenre ?? "default"}>
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
@@ -194,12 +173,15 @@ function FilterableTitleRowInner({
|
||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||
>
|
||||
<CarouselContent className="px-6 sm:px-2">
|
||||
{items.slice(0, 20).map((item) => (
|
||||
{items.slice(0, 20).map((item, i) => (
|
||||
<CarouselItem
|
||||
key={`${item.type}-${item.tmdbId}`}
|
||||
className="basis-auto pl-4 w-[140px] shrink-0 sm:w-[160px]"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<div
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<ExploreTitleCard
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.type}
|
||||
@@ -213,12 +195,12 @@ function FilterableTitleRowInner({
|
||||
episodeProgress[`${item.tmdbId}-${item.type}`]
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconPlus, IconStar } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -25,12 +24,7 @@ export function HeroBanner({
|
||||
const href = `/titles/tmdb-${tmdbId}-${type}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative -mt-6 mb-4 ml-[calc(-50vw+50%)] mr-[calc(-50vw+50%)] overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="animate-stagger-item relative -mt-6 mb-4 ml-[calc(-50vw+50%)] mr-[calc(-50vw+50%)] overflow-hidden">
|
||||
<div className="relative w-full min-h-[280px] max-h-[420px] aspect-[21/9]">
|
||||
{backdropPath ? (
|
||||
<Image
|
||||
@@ -52,15 +46,9 @@ export function HeroBanner({
|
||||
<div className="absolute inset-0 flex items-end">
|
||||
<div className="w-full px-4 pb-8 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 24,
|
||||
delay: 0.2,
|
||||
}}
|
||||
<div
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": 3 } as React.CSSProperties}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="rounded bg-primary/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
|
||||
@@ -91,11 +79,11 @@ export function HeroBanner({
|
||||
<IconPlus className="size-4" />
|
||||
Add to Library
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { motion } from "motion/react";
|
||||
import { ExploreTitleCard } from "@/components/title-card";
|
||||
import {
|
||||
Carousel,
|
||||
@@ -26,21 +25,6 @@ interface TitleRowProps {
|
||||
episodeProgress?: Record<string, { watched: number; total: number }>;
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
export function TitleRow({
|
||||
heading,
|
||||
icon,
|
||||
@@ -56,42 +40,39 @@ export function TitleRow({
|
||||
{icon}
|
||||
<h2 className="font-display text-xl tracking-tight">{heading}</h2>
|
||||
</div>
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||
>
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||
>
|
||||
<CarouselContent className="px-6 sm:px-2">
|
||||
{items.map((item) => (
|
||||
<CarouselItem
|
||||
key={`${item.type}-${item.tmdbId}`}
|
||||
className="basis-auto pl-4 w-[140px] shrink-0 sm:w-[160px]"
|
||||
<CarouselContent className="px-6 sm:px-2">
|
||||
{items.map((item, i) => (
|
||||
<CarouselItem
|
||||
key={`${item.type}-${item.tmdbId}`}
|
||||
className="basis-auto pl-4 w-[140px] shrink-0 sm:w-[160px]"
|
||||
>
|
||||
<div
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<ExploreTitleCard
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.type}
|
||||
title={item.title}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate}
|
||||
voteAverage={item.voteAverage}
|
||||
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
||||
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
||||
episodeProgress={
|
||||
episodeProgress?.[`${item.tmdbId}-${item.type}`]
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</motion.div>
|
||||
<ExploreTitleCard
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.type}
|
||||
title={item.title}
|
||||
posterPath={item.posterPath}
|
||||
releaseDate={item.releaseDate}
|
||||
voteAverage={item.voteAverage}
|
||||
href={`/titles/tmdb-${item.tmdbId}-${item.type}`}
|
||||
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
||||
episodeProgress={
|
||||
episodeProgress?.[`${item.tmdbId}-${item.type}`]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -10,21 +9,6 @@ import type { PersonCredit } from "@/lib/types/title";
|
||||
type Filter = "all" | "movie" | "tv";
|
||||
type Sort = "newest" | "rating";
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.04 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
interface FilmographyGridProps {
|
||||
credits: PersonCredit[];
|
||||
}
|
||||
@@ -103,15 +87,16 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
<div
|
||||
key={`${filter}-${sort}`}
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||
>
|
||||
{filtered.map((credit) => (
|
||||
<motion.div key={credit.titleId} variants={staggerItem}>
|
||||
{filtered.map((credit, i) => (
|
||||
<div
|
||||
key={credit.titleId}
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<Link href={`/titles/${credit.titleId}`} className="group">
|
||||
<div className="overflow-hidden rounded-xl bg-card ring-1 ring-white/[0.06] transition-all group-hover:ring-primary/25">
|
||||
<div className="aspect-[2/3] w-full bg-muted">
|
||||
@@ -150,9 +135,9 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import type { ResolvedPerson } from "@/lib/types/title";
|
||||
@@ -28,12 +27,7 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col gap-6 sm:flex-row sm:gap-8"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 28 }}
|
||||
>
|
||||
<div className="animate-stagger-item flex flex-col gap-6 sm:flex-row sm:gap-8">
|
||||
<div className="size-40 shrink-0 self-center overflow-hidden rounded-2xl ring-1 ring-white/10 shadow-2xl sm:size-56 sm:self-start">
|
||||
{person.profilePath ? (
|
||||
<Image
|
||||
@@ -105,6 +99,6 @@ export function PersonHero({ person }: PersonHeroProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { IconUser, IconUsers } from "@tabler/icons-react";
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -12,21 +11,6 @@ import {
|
||||
} from "@/components/ui/carousel";
|
||||
import type { CastMember } from "@/lib/types/title";
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.04 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
interface CastCarouselProps {
|
||||
actors: CastMember[];
|
||||
titleType: "movie" | "tv";
|
||||
@@ -41,69 +25,66 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
||||
</div>
|
||||
|
||||
{actors.length > 0 && (
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
dragFree: true,
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-4 sm:-mx-0"
|
||||
>
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
dragFree: true,
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-4 sm:-mx-0"
|
||||
>
|
||||
<CarouselContent className="px-4 sm:px-0">
|
||||
{actors.map((member) => (
|
||||
<CarouselItem
|
||||
key={member.id}
|
||||
className="w-[100px] shrink-0 basis-auto pl-4 sm:w-[120px]"
|
||||
<CarouselContent className="px-4 sm:px-0">
|
||||
{actors.map((member, i) => (
|
||||
<CarouselItem
|
||||
key={member.id}
|
||||
className="w-[100px] shrink-0 basis-auto pl-4 sm:w-[120px]"
|
||||
>
|
||||
<div
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Link
|
||||
href={`/people/${member.personId}`}
|
||||
className="group flex flex-col items-center gap-2"
|
||||
>
|
||||
<div className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24">
|
||||
{member.profilePath ? (
|
||||
<Image
|
||||
src={member.profilePath}
|
||||
alt={member.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
<IconUser className="size-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full text-center">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{member.name}
|
||||
<Link
|
||||
href={`/people/${member.personId}`}
|
||||
className="group flex flex-col items-center gap-2"
|
||||
>
|
||||
<div className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24">
|
||||
{member.profilePath ? (
|
||||
<Image
|
||||
src={member.profilePath}
|
||||
alt={member.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
<IconUser className="size-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full text-center">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{member.name}
|
||||
</p>
|
||||
{member.character && (
|
||||
<p className="truncate text-[10px] text-muted-foreground">
|
||||
{member.character}
|
||||
</p>
|
||||
{member.character && (
|
||||
<p className="truncate text-[10px] text-muted-foreground">
|
||||
{member.character}
|
||||
</p>
|
||||
)}
|
||||
{titleType === "tv" && member.episodeCount && (
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{member.episodeCount} ep
|
||||
{member.episodeCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</motion.div>
|
||||
)}
|
||||
{titleType === "tv" && member.episodeCount && (
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{member.episodeCount} ep
|
||||
{member.episodeCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import type { RecommendedTitle } from "@/lib/types/title";
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.05 } },
|
||||
};
|
||||
|
||||
const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
export function RecommendationsGrid({
|
||||
recommendations,
|
||||
}: {
|
||||
@@ -31,14 +15,13 @@ export function RecommendationsGrid({
|
||||
<IconSparkles className="size-5 text-primary" />
|
||||
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
|
||||
</div>
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{recommendations.slice(0, 12).map((rec) => (
|
||||
<motion.div key={rec.id} variants={staggerItem}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{recommendations.slice(0, 12).map((rec, i) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<TitleCard
|
||||
id={rec.id}
|
||||
tmdbId={rec.tmdbId}
|
||||
@@ -48,9 +31,9 @@ export function RecommendationsGrid({
|
||||
releaseDate={rec.releaseDate ?? rec.firstAirDate}
|
||||
voteAverage={rec.voteAverage}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,4 +148,21 @@
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* CSS stagger animation — plays immediately from SSR without waiting for JS hydration */
|
||||
.animate-stagger-item {
|
||||
animation: stagger-fade-in 0.35s ease-out both;
|
||||
animation-delay: calc(var(--stagger-index, 0) * 0.04s);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stagger-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,28 +154,20 @@ export function getUserStats(userId: string): DashboardStats {
|
||||
const moviesThisMonth = getWatchCount(userId, "movies", "this_month");
|
||||
const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
|
||||
|
||||
const [librarySizeRow] = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
const [statusCounts] = db
|
||||
.select({
|
||||
librarySize: sql<number>`count(*)`,
|
||||
completed: sql<number>`sum(case when ${userTitleStatus.status} = 'completed' then 1 else 0 end)`,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all();
|
||||
|
||||
const [completedCount] = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "completed"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
return {
|
||||
moviesThisMonth,
|
||||
episodesThisWeek,
|
||||
librarySize: librarySizeRow?.count ?? 0,
|
||||
completed: completedCount?.count ?? 0,
|
||||
librarySize: statusCounts?.librarySize ?? 0,
|
||||
completed: statusCounts?.completed ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -667,13 +667,10 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
}),
|
||||
);
|
||||
|
||||
// Lazy color extraction — await so the palette is available for theming
|
||||
let palette = parseColorPalette(title.colorPalette);
|
||||
// Color extraction — use cached palette if available, otherwise fire-and-forget
|
||||
const palette = parseColorPalette(title.colorPalette);
|
||||
if (!palette && title.posterPath) {
|
||||
palette =
|
||||
(await extractAndStoreColors(title.id, title.posterPath).catch(
|
||||
() => null,
|
||||
)) ?? null;
|
||||
extractAndStoreColors(title.id, title.posterPath).catch(() => {});
|
||||
}
|
||||
|
||||
const resolvedTitle: ResolvedTitle = {
|
||||
|
||||
Reference in New Issue
Block a user