diff --git a/app/(pages)/dashboard/_components/continue-watching-list.tsx b/app/(pages)/dashboard/_components/continue-watching-list.tsx index 4de23fb..41ed565 100644 --- a/app/(pages)/dashboard/_components/continue-watching-list.tsx +++ b/app/(pages)/dashboard/_components/continue-watching-list.tsx @@ -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 ( - - - - {items.map((item) => ( - - - - - - ))} - - - + + + {items.map((item, i) => ( + +
+ +
+
+ ))} +
+
); } diff --git a/app/(pages)/dashboard/_components/stats-display.tsx b/app/(pages)/dashboard/_components/stats-display.tsx index bb2ae2a..ae14099 100644 --- a/app/(pages)/dashboard/_components/stats-display.tsx +++ b/app/(pages)/dashboard/_components/stats-display.tsx @@ -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 ( - {sparklineData && }
@@ -83,11 +74,12 @@ function StatCard({

{value}

-
+ ); } @@ -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={ - {items.map((t) => ( - +
+ {items.map((t, i) => ( +
- +
))} - +
); } diff --git a/app/(pages)/dashboard/loading.tsx b/app/(pages)/dashboard/loading.tsx deleted file mode 100644 index ef27e6b..0000000 --- a/app/(pages)/dashboard/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { DashboardSkeleton } from "@/components/skeletons"; - -export default function DashboardLoading() { - return ; -} diff --git a/app/(pages)/explore/filterable-title-row.tsx b/app/(pages)/explore/filterable-title-row.tsx index 3383d83..3c56553 100644 --- a/app/(pages)/explore/filterable-title-row.tsx +++ b/app/(pages)/explore/filterable-title-row.tsx @@ -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; } -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 && ( - +
- {items.slice(0, 20).map((item) => ( + {items.slice(0, 20).map((item, i) => ( - +
- +
))}
- +
)} ); diff --git a/app/(pages)/explore/hero-banner.tsx b/app/(pages)/explore/hero-banner.tsx index c619b82..1f1e723 100644 --- a/app/(pages)/explore/hero-banner.tsx +++ b/app/(pages)/explore/hero-banner.tsx @@ -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 ( - +
{backdropPath ? (
-
@@ -91,11 +79,11 @@ export function HeroBanner({ Add to Library - +
-
+ ); } diff --git a/app/(pages)/explore/title-row.tsx b/app/(pages)/explore/title-row.tsx index ddc716d..8ef11cd 100644 --- a/app/(pages)/explore/title-row.tsx +++ b/app/(pages)/explore/title-row.tsx @@ -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; } -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}

{heading}

- - - - {items.map((item) => ( - + {items.map((item, i) => ( + +
- - - - - ))} - - - + +
+
+ ))} +
+
); } diff --git a/app/(pages)/people/[id]/_components/filmography-grid.tsx b/app/(pages)/people/[id]/_components/filmography-grid.tsx index 05c9b94..490a10d 100644 --- a/app/(pages)/people/[id]/_components/filmography-grid.tsx +++ b/app/(pages)/people/[id]/_components/filmography-grid.tsx @@ -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) { ))} - - {filtered.map((credit) => ( - + {filtered.map((credit, i) => ( +
@@ -150,9 +135,9 @@ export function FilmographyGrid({ credits }: FilmographyGridProps) {
- +
))} -
+ ); } diff --git a/app/(pages)/people/[id]/_components/person-hero.tsx b/app/(pages)/people/[id]/_components/person-hero.tsx index 02e460f..df49f11 100644 --- a/app/(pages)/people/[id]/_components/person-hero.tsx +++ b/app/(pages)/people/[id]/_components/person-hero.tsx @@ -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 ( - +
{person.profilePath ? ( )}
- +
); } diff --git a/app/(pages)/titles/[id]/_components/cast-carousel.tsx b/app/(pages)/titles/[id]/_components/cast-carousel.tsx index 1d870fe..a6773b2 100644 --- a/app/(pages)/titles/[id]/_components/cast-carousel.tsx +++ b/app/(pages)/titles/[id]/_components/cast-carousel.tsx @@ -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) { {actors.length > 0 && ( - - - - {actors.map((member) => ( - + {actors.map((member, i) => ( + +
- - -
- {member.profilePath ? ( - {member.name} - ) : ( -
- -
- )} -
-
-

- {member.name} + +

+ {member.profilePath ? ( + {member.name} + ) : ( +
+ +
+ )} +
+
+

+ {member.name} +

+ {member.character && ( +

+ {member.character}

- {member.character && ( -

- {member.character} -

- )} - {titleType === "tv" && member.episodeCount && ( -

- {member.episodeCount} ep - {member.episodeCount !== 1 ? "s" : ""} -

- )} -
- - - - ))} - - - + )} + {titleType === "tv" && member.episodeCount && ( +

+ {member.episodeCount} ep + {member.episodeCount !== 1 ? "s" : ""} +

+ )} +
+ +
+
+ ))} +
+
)} ); diff --git a/app/(pages)/titles/[id]/_components/recommendations-grid.tsx b/app/(pages)/titles/[id]/_components/recommendations-grid.tsx index 5d7303a..315a43e 100644 --- a/app/(pages)/titles/[id]/_components/recommendations-grid.tsx +++ b/app/(pages)/titles/[id]/_components/recommendations-grid.tsx @@ -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({

Recommended

- - {recommendations.slice(0, 12).map((rec) => ( - +
+ {recommendations.slice(0, 12).map((rec, i) => ( +
- +
))} - +
); } diff --git a/app/globals.css b/app/globals.css index 7f96e87..cfec18b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); + } } diff --git a/lib/services/discovery.ts b/lib/services/discovery.ts index 2a9988e..0b05e77 100644 --- a/lib/services/discovery.ts +++ b/lib/services/discovery.ts @@ -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`count(*)` }) + const [statusCounts] = db + .select({ + librarySize: sql`count(*)`, + completed: sql`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`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, }; } diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 57e2bea..0f6b82d 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -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 = {