Replace Carousel with ScrollArea, remove embla-carousel dependency

- Delete carousel.tsx and replace all Carousel/CarouselContent/CarouselItem
  usage across title rows, cast carousel, continue watching, and explore
  sections with ScrollArea + scrollFade horizontal scroll
- Update ScrollArea to accept a `scrollFade` prop that renders a
  gradient edge overlay instead of the old per-site absolute div hack
- Remove "use client" directive from title-row and cast-carousel now
  that they have no client-side hooks
- Delete unused resizable.tsx; inline TypeBadge icon into TitleHero
  and delete the standalone type-badge.tsx file
- Add TitleTheme component for per-title accent color CSS injection
- Swap IconSparkles → IconThumbUp in recommendations section and grid
- Simplify ambient glow to a single size (remove mobile breakpoint override)
This commit is contained in:
2026-03-08 09:53:12 -04:00
parent 7ed172d675
commit 02466e94f9
20 changed files with 207 additions and 576 deletions
@@ -1,8 +1,4 @@
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ContinueWatchingCard,
type ContinueWatchingItemProps,
@@ -14,22 +10,22 @@ export function ContinueWatchingList({
items: ContinueWatchingItemProps[];
}) {
return (
<Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
className="-mx-4 sm:-mx-0 [&>[data-slot=carousel-content]]:px-px"
<ScrollArea
scrollFade
className="-mx-4 sm:-mx-0 [&_[data-slot=scroll-area-content]]:px-px"
>
<CarouselContent className="px-4 sm:px-0">
<div className="flex gap-4 px-4 py-2 sm:px-0">
{items.map((item, i) => (
<CarouselItem key={item.title.id} className="basis-auto pl-4">
<div key={item.title.id} className="shrink-0">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<ContinueWatchingCard item={item} />
</div>
</CarouselItem>
</div>
))}
</CarouselContent>
</Carousel>
</div>
</ScrollArea>
);
}
@@ -1,4 +1,4 @@
import { IconSparkles } from "@tabler/icons-react";
import { IconThumbUp } from "@tabler/icons-react";
import { getRecommendationsFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FeedSection } from "./feed-section";
@@ -24,7 +24,7 @@ export async function RecommendationsSection({ userId }: { userId: string }) {
return (
<FeedSection
title="Recommended for You"
icon={<IconSparkles className="size-5 text-primary" />}
icon={<IconThumbUp className="size-5 text-primary" />}
>
<TitleGrid items={items} />
</FeedSection>
@@ -4,11 +4,7 @@ import { useState, useTransition } from "react";
import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { discoverByGenre } from "@/lib/actions/explore";
import {
fetchEpisodeProgress,
@@ -105,8 +101,8 @@ export function FilterableTitleRow({
</div>
{/* Genre chips */}
<div className="relative">
<div className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
<ScrollArea scrollFade>
<div className="flex gap-2">
{genres.map((genre) => (
<Button
key={genre.id}
@@ -123,8 +119,7 @@ export function FilterableTitleRow({
</Button>
))}
</div>
<div className="pointer-events-none absolute top-0 right-0 h-full w-8 bg-gradient-to-l from-background to-transparent sm:hidden" />
</div>
</ScrollArea>
{/* Loading skeleton */}
{isPending && (
@@ -148,45 +143,40 @@ export function FilterableTitleRow({
</p>
)}
{/* Carousel */}
{/* Title cards */}
{!isPending && items.length > 0 && (
<div key={selectedGenre ?? "default"}>
<Carousel
opts={{
align: "start",
dragFree: true,
containScroll: "trimSnaps",
}}
className="carousel-tilt -mx-6 sm:-mx-2"
>
<CarouselContent className="px-6 sm:px-2">
{items.slice(0, 20).map((item, i) => (
<CarouselItem
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 basis-auto pl-4 sm:w-[160px]"
<ScrollArea
key={selectedGenre ?? "default"}
scrollFade
className="-mx-6 sm:-mx-2"
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.slice(0, 20).map((item, i) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
>
<TitleCard
tmdbId={item.tmdbId}
type={item.type}
title={item.title}
posterPath={item.posterPath}
releaseDate={item.releaseDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
/>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
<TitleCard
tmdbId={item.tmdbId}
type={item.type}
title={item.title}
posterPath={item.posterPath}
releaseDate={item.releaseDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`]
}
/>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</section>
);
+8 -17
View File
@@ -1,11 +1,5 @@
"use client";
import { TitleCard } from "@/components/title-card";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { ScrollArea } from "@/components/ui/scroll-area";
interface TitleRowItem {
tmdbId: number;
@@ -41,15 +35,12 @@ export function TitleRow({
{heading}
</h2>
</div>
<Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
className="carousel-tilt -mx-6 sm:-mx-2"
>
<CarouselContent className="px-6 sm:px-2">
<ScrollArea scrollFade className="-mx-6 sm:-mx-2">
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<CarouselItem
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 basis-auto pl-4 sm:w-[160px]"
className="w-[140px] shrink-0 sm:w-[160px]"
>
<div
className="animate-stagger-item"
@@ -68,10 +59,10 @@ export function TitleRow({
}
/>
</div>
</CarouselItem>
</div>
))}
</CarouselContent>
</Carousel>
</div>
</ScrollArea>
</section>
);
}
+1 -1
View File
@@ -30,7 +30,7 @@ async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
<div className="relative z-0 min-h-screen pb-[calc(3.5rem+env(safe-area-inset-bottom))] sm:pb-0">
<NavBar userName={session.user.name} />
{/* Ambient glow — smaller on mobile to add warmth without overwhelming */}
<div className="pointer-events-none fixed top-1/4 left-1/2 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/3 blur-[120px] sm:h-[600px] sm:w-[800px] sm:blur-[200px]" />
<div className="pointer-events-none fixed top-1/4 left-1/2 h-[600px] w-[800px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/3 blur-[200px]" />
<main
id="main-content"
className="relative mx-auto max-w-6xl py-6 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]"
@@ -1,13 +1,7 @@
"use client";
import { IconUser, IconUsers } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { CastMember } from "@/lib/types/title";
interface CastCarouselProps {
@@ -24,20 +18,10 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
</div>
{actors.length > 0 && (
<Carousel
opts={{
align: "start",
dragFree: true,
containScroll: "trimSnaps",
}}
className="-mx-4 sm:-mx-0"
>
<CarouselContent className="px-4 sm:px-0">
<ScrollArea scrollFade className="-mx-4 sm:-mx-0">
<div className="flex gap-4 px-4 py-2 sm:px-0">
{actors.map((member, i) => (
<CarouselItem
key={member.id}
className="w-[100px] shrink-0 basis-auto pl-4 sm:w-[120px]"
>
<div key={member.id} className="w-[100px] shrink-0 sm:w-[120px]">
<div
className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties}
@@ -82,10 +66,10 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
</div>
</Link>
</div>
</CarouselItem>
</div>
))}
</CarouselContent>
</Carousel>
</div>
</ScrollArea>
)}
</section>
);
@@ -29,7 +29,7 @@ export function GenreCollapse({ genres }: { genres: string[] }) {
{remaining.map((genre) => (
<span
key={genre}
className="px-2 py-1 text-popover-foreground text-xs"
className="px-2 py-1 text-[13px] text-popover-foreground"
>
{genre}
</span>
@@ -1,6 +1,6 @@
"use client";
import { IconSparkles } from "@tabler/icons-react";
import { IconThumbUp } from "@tabler/icons-react";
import { TitleCard } from "@/components/title-card";
import type { RecommendedTitle } from "@/lib/types/title";
@@ -14,8 +14,8 @@ export function RecommendationsGrid({
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconSparkles aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-xl tracking-tight">Recommended</h2>
</div>
<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) => (
@@ -36,7 +36,10 @@ export function TitleActions() {
Mark Watched
</Button>
)}
<Separator orientation="vertical" className="mx-0.5 h-4 bg-border/50" />
<Separator
orientation="vertical"
className="mx-0.5 my-auto h-6 bg-border/50"
/>
<StarRating value={userRating ?? 0} onChange={handleRating} />
</div>
);
@@ -2,7 +2,9 @@ import {
IconCalendarEvent,
IconCircleCheck,
IconCircleX,
IconDeviceTv,
IconLoader,
IconMovie,
IconRefresh,
IconStarFilled,
} from "@tabler/icons-react";
@@ -13,7 +15,6 @@ import { TmdbLogo } from "@/components/tmdb-logo";
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
import { GenreCollapse } from "./genre-collapse";
import { TrailerDialog } from "./trailer-dialog";
import { TypeBadge } from "./type-badge";
export function TitleHero({
title,
@@ -69,7 +70,7 @@ export function TitleHero({
}}
/>
{trailerVideoKey && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="absolute inset-0 z-10 mb-12 flex items-center justify-center">
<TrailerDialog videoKey={trailerVideoKey} variant="backdrop" />
</div>
)}
@@ -106,106 +107,65 @@ export function TitleHero({
)}
<div className="flex-1 space-y-5">
<div>
<div className="space-y-1.5">
<h1 className="text-balance font-display text-2xl tracking-tight md:text-5xl">
{title.title}
</h1>
<div className="mt-2 space-y-1.5 text-muted-foreground text-sm md:space-y-0">
{/* Desktop: single row with dot separators */}
<div className="hidden md:flex md:items-center md:gap-x-2 md:gap-y-1">
<TypeBadge type={title.type} />
<div className="flex items-center gap-x-2 [&>*+*]:before:mr-2 [&>*+*]:before:text-border [&>*+*]:before:content-['·']">
{title.contentRating && <span>{title.contentRating}</span>}
{year && <span>{year}</span>}
{title.genres.length > 0 && (
<GenreCollapse genres={title.genres} />
)}
{title.voteAverage != null && title.voteAverage > 0 && (
<span className="inline-flex items-center gap-1 text-primary">
<IconStarFilled className="size-3.5" />
{title.voteAverage.toFixed(1)}
</span>
)}
{title.status &&
!(
title.type === "movie" && title.status === "Released"
) && (
<span className="inline-flex items-center gap-1">
<StatusIcon status={title.status} />
{title.status}
</span>
)}
{/* Desktop: single row with dot separators */}
<div className="flex flex-wrap items-center gap-x-3.5 gap-y-2 text-muted-foreground text-sm md:gap-x-5">
<div className="inline-flex cursor-default items-center justify-center gap-1.5 rounded bg-primary/10 px-1 py-1 font-medium text-primary text-xs md:px-1.5">
{title.type === "movie" ? (
<>
<IconMovie
aria-hidden
className="size-3.5 translate-y-[-0.5px]"
/>
<span className="hidden md:inline">Movie</span>
</>
) : (
<>
<IconDeviceTv
aria-hidden
className="size-3.5 translate-y-[-0.5px]"
/>
<span className="hidden md:inline">TV</span>
</>
)}
</div>
{title.contentRating && (
<div className="inline-flex border border-muted-foreground/50 px-1.5 font-medium text-[13px]">
{title.contentRating}
</div>
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
aria-label="View on TMDB"
className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40"
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
</div>
{/* Mobile: two clean rows */}
<div className="flex items-center gap-2 md:hidden">
<TypeBadge type={title.type} />
{title.contentRating && <span>{title.contentRating}</span>}
{year && (
<>
{title.contentRating && (
<span className="text-border">·</span>
)}
<span>{year}</span>
</>
)}
{title.genres.length > 0 && (
<>
<span className="text-border">·</span>
<GenreCollapse genres={title.genres} />
</>
)}
{title.voteAverage != null && title.voteAverage > 0 && (
<>
<span className="text-border">·</span>
<span className="inline-flex items-center gap-1 text-primary">
<IconStarFilled className="size-3.5" />
{title.voteAverage.toFixed(1)}
</span>
</>
)}
</div>
{title.status &&
!(title.type === "movie" && title.status === "Released") && (
<div className="flex items-center gap-2 md:hidden">
<span className="inline-flex items-center gap-1 text-muted-foreground/70">
<StatusIcon status={title.status} />
{title.status}
</span>
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
aria-label="View on TMDB"
className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40"
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
</div>
)}
{/* TMDB link for mobile when no status is shown */}
{(!title.status ||
(title.type === "movie" && title.status === "Released")) && (
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
aria-label="View on TMDB"
className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40 md:hidden"
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
)}
{year && <span>{year}</span>}
{title.genres.length > 0 && (
<GenreCollapse genres={title.genres} />
)}
{title.voteAverage != null && title.voteAverage > 0 && (
<span className="inline-flex items-center gap-1 text-primary">
<IconStarFilled className="size-3.5 translate-y-[-0.5px]" />
{title.voteAverage.toFixed(1)}
</span>
)}
{title.status &&
!(title.type === "movie" && title.status === "Released") &&
!(
title.type === "tv" && title.status === "Returning Series"
) && (
<span className="inline-flex items-center gap-1">
<StatusIcon status={title.status} />
{title.status}
</span>
)}
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
aria-label="View on TMDB"
className="inline-flex items-center opacity-70 transition-opacity hover:opacity-40"
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
</div>
</div>
@@ -245,7 +205,7 @@ function AmbientGlow({ palette }: { palette: ColorPalette | null }) {
{/* Mobile: single full-bleed color wash that flows from the backdrop edge-to-edge */}
{glowColor && (
<div
className="pointer-events-none absolute top-0 right-[calc(-50vw+50%)] left-[calc(-50vw+50%)] -z-10 h-[600px] md:hidden"
className="pointer-events-none absolute top-0 right-[calc(-50dvw+50%)] left-[calc(-50dvw+50%)] -z-10 h-[600px] md:hidden"
style={{
background: `radial-gradient(ellipse 100% 60% at 50% 0%, ${glowColor}18 0%, transparent 70%)`,
}}
@@ -253,7 +213,7 @@ function AmbientGlow({ palette }: { palette: ColorPalette | null }) {
)}
{/* Desktop: multi-orb ambient glow with room to breathe */}
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 hidden h-[800px] overflow-hidden md:block">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 hidden h-[800px] md:block">
{palette.vibrant && (
<div
className="absolute top-16 -left-32 h-[500px] w-[500px] rounded-full opacity-[0.07] blur-[120px]"
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
const THEME_PROPERTIES = [
"--primary",
"--ring",
"--primary-foreground",
"--status-watching",
] as const;
export function TitleTheme({ style }: { style: Record<string, string> }) {
useEffect(() => {
const root = document.documentElement;
const entries = THEME_PROPERTIES.filter((key) => key in style);
for (const key of entries) {
root.style.setProperty(key, style[key]);
}
return () => {
for (const key of entries) {
root.style.removeProperty(key);
}
};
}, [style]);
return null;
}
@@ -1,25 +0,0 @@
"use client";
import { IconDeviceTv, IconMovie } from "@tabler/icons-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function TypeBadge({ type }: { type: "movie" | "tv" }) {
const isMovie = type === "movie";
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default items-center justify-center rounded bg-primary/10 p-1 text-primary">
{isMovie ? (
<IconMovie aria-hidden className="size-3.5" />
) : (
<IconDeviceTv aria-hidden className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent>{isMovie ? "Movie" : "TV Series"}</TooltipContent>
</Tooltip>
);
}
+2
View File
@@ -22,6 +22,7 @@ import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
import { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations";
import { TitleSeasons } from "./_components/title-seasons";
import { TitleTheme } from "./_components/title-theme";
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
@@ -68,6 +69,7 @@ export default async function TitleDetailPage({
return (
<div className="relative space-y-10" style={themeStyle}>
<TitleTheme style={themeStyle as Record<string, string>} />
<TitleProvider
key={title.id}
titleId={title.id}
-6
View File
@@ -143,12 +143,6 @@
display: none;
}
/* Allow tilt/scale effects to overflow carousel viewport vertically */
.carousel-tilt > [data-slot="carousel-content"] {
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;
+3 -18
View File
@@ -9,7 +9,7 @@
"@better-auth/drizzle-adapter": "1.5.4",
"@player.style/sutro": "0.2.1",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.3.2",
"@tanstack/react-hotkeys": "0.4.0",
"better-auth": "1.5.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -17,8 +17,6 @@
"croner": "10.0.2-dev.2",
"date-fns": "4.1.0",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"jotai": "2.18.0",
"media-chrome": "4.18.0",
"motion": "12.35.1",
@@ -27,7 +25,6 @@
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-resizable-panels": "4.7.1",
"recharts": "3.8.0",
"shadcn": "4.0.0",
"sonner": "2.0.7",
@@ -519,9 +516,9 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
"@tanstack/hotkeys": ["@tanstack/hotkeys@0.3.2", "", { "dependencies": { "@tanstack/store": "^0.9.1" } }, "sha512-+ihZ/aaD2kvKLasLMdKL9bPASMruJhfVT052deIBMSVb5yuB1x82MpPuGsaKJy6A74fGOdmlxLcARBKXG1ZIaQ=="],
"@tanstack/hotkeys": ["@tanstack/hotkeys@0.4.0", "", { "dependencies": { "@tanstack/store": "^0.9.2" } }, "sha512-VH+jGC5pszD1AmcDJKMcV52AETQVBKiALomY9Xgo13GihTdyEeEwBnS7RI6WDHIV03UFcWpCfwu2k8fBaKN7bw=="],
"@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.3.2", "", { "dependencies": { "@tanstack/hotkeys": "0.3.2", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-tSYs4N65I87coXO6D4D24QkWdrQVsldRBxpPubr+gE7P1bU+a8j8B1Lv379j+TcCtpK1IzgJ88SCkqS9dyf49g=="],
"@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.4.0", "", { "dependencies": { "@tanstack/hotkeys": "0.4.0", "@tanstack/react-store": "^0.9.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-ttAaGZZtkLOx30xoS3zNCrGvbA93cqvoWu7kaAFyJrHuxMcnswhtEAk4xxz2QbCL0XoQ5uB6fxf6n/PbCZ+pdQ=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
@@ -807,14 +804,6 @@
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"embla-carousel-wheel-gestures": ["embla-carousel-wheel-gestures@8.1.0", "", { "dependencies": { "wheel-gestures": "^2.2.5" }, "peerDependencies": { "embla-carousel": "^8.0.0 || ~8.0.0-rc03" } }, "sha512-J68jkYrxbWDmXOm2n2YHl+uMEXzkGSKjWmjaEgL9xVvPb3HqVmg6rJSKfI3sqIDVvm7mkeTy87wtG/5263XqHQ=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
@@ -1293,8 +1282,6 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.7.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RYBRgvdZhnUds5jJWYr1up0hYFofgN1dqSwhxfBl9Savoxms0gyGF0AfaXskhxTYkXrlwc+TlQPe5UkoV+1neg=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
@@ -1511,8 +1498,6 @@
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"wheel-gestures": ["wheel-gestures@2.2.48", "", {}, "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
+1 -6
View File
@@ -179,12 +179,7 @@ export function TitleDetailSkeleton() {
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-28 rounded-lg" />
<Skeleton className="h-4 w-px" />
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton
<Skeleton key={i} className="size-5 rounded" />
))}
</div>
<Skeleton className="h-5 w-24 rounded" />
</div>
</div>
</div>
-247
View File
@@ -1,247 +0,0 @@
"use client";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const defaultPlugins = [WheelGesturesPlugin({ forceWheelAxis: "x" })];
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins ?? defaultPlugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
{/* biome-ignore lint/a11y/useSemanticElements: shadcn generated */}
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
aria-label="Carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn generated
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<IconChevronLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<IconChevronRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
};
-50
View File
@@ -1,50 +0,0 @@
"use client";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
+41 -14
View File
@@ -1,30 +1,58 @@
"use client";
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
// biome-ignore lint/correctness/noUnusedImports: shadcn generated
import * as React from "react";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
scrollFade = true,
scrollbarGutter = false,
hideScrollbar = false,
scrollRef,
contentRef,
...props
}: ScrollAreaPrimitive.Root.Props) {
}: ScrollAreaPrimitive.Root.Props & {
/** Fade the edges of the scroll area to indicate scrollability */
scrollFade?: boolean;
/** Leave extra space for the scrollbar, instead of it covering the content */
scrollbarGutter?: boolean;
/** Completely hide scrollbars (useful for touch/gesture-only scroll areas) */
hideScrollbar?: boolean;
/** Ref for the outer viewport element */
scrollRef?: React.Ref<HTMLDivElement>;
/** Ref for the inner content element */
contentRef?: React.Ref<HTMLDivElement>;
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
className={cn("relative flex min-h-0 min-w-0 overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
ref={scrollRef}
className={cn(
"no-scrollbar flex-1 rounded-[inherit] outline-none focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-has-overflow-x:overscroll-x-contain",
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
scrollbarGutter &&
"data-has-overflow-y:pr-2.5 data-has-overflow-x:pb-2.5",
)}
>
{children}
<ScrollAreaPrimitive.Content
data-slot="scroll-area-content"
ref={contentRef}
>
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
{!hideScrollbar && (
<>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</>
)}
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
</ScrollAreaPrimitive.Root>
);
}
@@ -37,17 +65,16 @@ function ScrollBar({
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none select-none p-px transition-colors data-horizontal:h-2.5 data-vertical:h-full data-vertical:w-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:border-l data-vertical:border-l-transparent",
"m-1 flex opacity-0 transition-opacity delay-300 data-[orientation=horizontal]:h-1 data-[orientation=vertical]:w-1 data-[orientation=horizontal]:flex-col data-hovering:opacity-100 data-scrolling:opacity-100 data-hovering:delay-0 data-scrolling:delay-0 data-hovering:duration-100 data-scrolling:duration-100",
className,
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
className="relative flex-1 rounded-full bg-black/40 dark:bg-white/40"
/>
</ScrollAreaPrimitive.Scrollbar>
);
+1 -4
View File
@@ -22,7 +22,7 @@
"@better-auth/drizzle-adapter": "1.5.4",
"@player.style/sutro": "0.2.1",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.3.2",
"@tanstack/react-hotkeys": "0.4.0",
"better-auth": "1.5.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -30,8 +30,6 @@
"croner": "10.0.2-dev.2",
"date-fns": "4.1.0",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"jotai": "2.18.0",
"media-chrome": "4.18.0",
"motion": "12.35.1",
@@ -40,7 +38,6 @@
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-resizable-panels": "4.7.1",
"recharts": "3.8.0",
"shadcn": "4.0.0",
"sonner": "2.0.7",