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 { import { ScrollArea } from "@/components/ui/scroll-area";
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { import {
ContinueWatchingCard, ContinueWatchingCard,
type ContinueWatchingItemProps, type ContinueWatchingItemProps,
@@ -14,22 +10,22 @@ export function ContinueWatchingList({
items: ContinueWatchingItemProps[]; items: ContinueWatchingItemProps[];
}) { }) {
return ( return (
<Carousel <ScrollArea
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }} scrollFade
className="-mx-4 sm:-mx-0 [&>[data-slot=carousel-content]]:px-px" 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) => ( {items.map((item, i) => (
<CarouselItem key={item.title.id} className="basis-auto pl-4"> <div key={item.title.id} className="shrink-0">
<div <div
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
> >
<ContinueWatchingCard item={item} /> <ContinueWatchingCard item={item} />
</div> </div>
</CarouselItem> </div>
))} ))}
</CarouselContent> </div>
</Carousel> </ScrollArea>
); );
} }
@@ -1,4 +1,4 @@
import { IconSparkles } from "@tabler/icons-react"; import { IconThumbUp } from "@tabler/icons-react";
import { getRecommendationsFeed } from "@/lib/services/discovery"; import { getRecommendationsFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FeedSection } from "./feed-section"; import { FeedSection } from "./feed-section";
@@ -24,7 +24,7 @@ export async function RecommendationsSection({ userId }: { userId: string }) {
return ( return (
<FeedSection <FeedSection
title="Recommended for You" title="Recommended for You"
icon={<IconSparkles className="size-5 text-primary" />} icon={<IconThumbUp className="size-5 text-primary" />}
> >
<TitleGrid items={items} /> <TitleGrid items={items} />
</FeedSection> </FeedSection>
@@ -4,11 +4,7 @@ import { useState, useTransition } from "react";
import { TitleCardSkeleton } from "@/components/skeletons"; import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { ScrollArea } from "@/components/ui/scroll-area";
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { discoverByGenre } from "@/lib/actions/explore"; import { discoverByGenre } from "@/lib/actions/explore";
import { import {
fetchEpisodeProgress, fetchEpisodeProgress,
@@ -105,8 +101,8 @@ export function FilterableTitleRow({
</div> </div>
{/* Genre chips */} {/* Genre chips */}
<div className="relative"> <ScrollArea scrollFade>
<div className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0"> <div className="flex gap-2">
{genres.map((genre) => ( {genres.map((genre) => (
<Button <Button
key={genre.id} key={genre.id}
@@ -123,8 +119,7 @@ export function FilterableTitleRow({
</Button> </Button>
))} ))}
</div> </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" /> </ScrollArea>
</div>
{/* Loading skeleton */} {/* Loading skeleton */}
{isPending && ( {isPending && (
@@ -148,22 +143,18 @@ export function FilterableTitleRow({
</p> </p>
)} )}
{/* Carousel */} {/* Title cards */}
{!isPending && items.length > 0 && ( {!isPending && items.length > 0 && (
<div key={selectedGenre ?? "default"}> <ScrollArea
<Carousel key={selectedGenre ?? "default"}
opts={{ scrollFade
align: "start", className="-mx-6 sm:-mx-2"
dragFree: true,
containScroll: "trimSnaps",
}}
className="carousel-tilt -mx-6 sm:-mx-2"
> >
<CarouselContent className="px-6 sm:px-2"> <div className="flex gap-4 px-6 py-2 sm:px-2">
{items.slice(0, 20).map((item, i) => ( {items.slice(0, 20).map((item, i) => (
<CarouselItem <div
key={`${item.type}-${item.tmdbId}`} 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 <div
className="animate-stagger-item" className="animate-stagger-item"
@@ -182,11 +173,10 @@ export function FilterableTitleRow({
} }
/> />
</div> </div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div> </div>
))}
</div>
</ScrollArea>
)} )}
</section> </section>
); );
+8 -17
View File
@@ -1,11 +1,5 @@
"use client";
import { TitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
import { import { ScrollArea } from "@/components/ui/scroll-area";
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
interface TitleRowItem { interface TitleRowItem {
tmdbId: number; tmdbId: number;
@@ -41,15 +35,12 @@ export function TitleRow({
{heading} {heading}
</h2> </h2>
</div> </div>
<Carousel <ScrollArea scrollFade className="-mx-6 sm:-mx-2">
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }} <div className="flex gap-4 px-6 py-2 sm:px-2">
className="carousel-tilt -mx-6 sm:-mx-2"
>
<CarouselContent className="px-6 sm:px-2">
{items.map((item, i) => ( {items.map((item, i) => (
<CarouselItem <div
key={`${item.type}-${item.tmdbId}`} 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 <div
className="animate-stagger-item" className="animate-stagger-item"
@@ -68,10 +59,10 @@ export function TitleRow({
} }
/> />
</div> </div>
</CarouselItem> </div>
))} ))}
</CarouselContent> </div>
</Carousel> </ScrollArea>
</section> </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"> <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} /> <NavBar userName={session.user.name} />
{/* Ambient glow — smaller on mobile to add warmth without overwhelming */} {/* 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 <main
id="main-content" 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))]" 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 { IconUser, IconUsers } from "@tabler/icons-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { import { ScrollArea } from "@/components/ui/scroll-area";
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import type { CastMember } from "@/lib/types/title"; import type { CastMember } from "@/lib/types/title";
interface CastCarouselProps { interface CastCarouselProps {
@@ -24,20 +18,10 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
</div> </div>
{actors.length > 0 && ( {actors.length > 0 && (
<Carousel <ScrollArea scrollFade className="-mx-4 sm:-mx-0">
opts={{ <div className="flex gap-4 px-4 py-2 sm:px-0">
align: "start",
dragFree: true,
containScroll: "trimSnaps",
}}
className="-mx-4 sm:-mx-0"
>
<CarouselContent className="px-4 sm:px-0">
{actors.map((member, i) => ( {actors.map((member, i) => (
<CarouselItem <div key={member.id} className="w-[100px] shrink-0 sm:w-[120px]">
key={member.id}
className="w-[100px] shrink-0 basis-auto pl-4 sm:w-[120px]"
>
<div <div
className="animate-stagger-item" className="animate-stagger-item"
style={{ "--stagger-index": i } as React.CSSProperties} style={{ "--stagger-index": i } as React.CSSProperties}
@@ -82,10 +66,10 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
</div> </div>
</Link> </Link>
</div> </div>
</CarouselItem> </div>
))} ))}
</CarouselContent> </div>
</Carousel> </ScrollArea>
)} )}
</section> </section>
); );
@@ -29,7 +29,7 @@ export function GenreCollapse({ genres }: { genres: string[] }) {
{remaining.map((genre) => ( {remaining.map((genre) => (
<span <span
key={genre} key={genre}
className="px-2 py-1 text-popover-foreground text-xs" className="px-2 py-1 text-[13px] text-popover-foreground"
> >
{genre} {genre}
</span> </span>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { IconSparkles } from "@tabler/icons-react"; import { IconThumbUp } from "@tabler/icons-react";
import { TitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
import type { RecommendedTitle } from "@/lib/types/title"; import type { RecommendedTitle } from "@/lib/types/title";
@@ -14,8 +14,8 @@ export function RecommendationsGrid({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconSparkles aria-hidden={true} className="size-5 text-primary" /> <IconThumbUp aria-hidden={true} className="size-5 text-primary" />
<h2 className="font-display text-2xl tracking-tight">Recommended</h2> <h2 className="font-display text-xl tracking-tight">Recommended</h2>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"> <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) => ( {recommendations.slice(0, 12).map((rec, i) => (
@@ -36,7 +36,10 @@ export function TitleActions() {
Mark Watched Mark Watched
</Button> </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} /> <StarRating value={userRating ?? 0} onChange={handleRating} />
</div> </div>
); );
@@ -2,7 +2,9 @@ import {
IconCalendarEvent, IconCalendarEvent,
IconCircleCheck, IconCircleCheck,
IconCircleX, IconCircleX,
IconDeviceTv,
IconLoader, IconLoader,
IconMovie,
IconRefresh, IconRefresh,
IconStarFilled, IconStarFilled,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -13,7 +15,6 @@ import { TmdbLogo } from "@/components/tmdb-logo";
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title"; import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
import { GenreCollapse } from "./genre-collapse"; import { GenreCollapse } from "./genre-collapse";
import { TrailerDialog } from "./trailer-dialog"; import { TrailerDialog } from "./trailer-dialog";
import { TypeBadge } from "./type-badge";
export function TitleHero({ export function TitleHero({
title, title,
@@ -69,7 +70,7 @@ export function TitleHero({
}} }}
/> />
{trailerVideoKey && ( {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" /> <TrailerDialog videoKey={trailerVideoKey} variant="backdrop" />
</div> </div>
)} )}
@@ -106,36 +107,56 @@ export function TitleHero({
)} )}
<div className="flex-1 space-y-5"> <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"> <h1 className="text-balance font-display text-2xl tracking-tight md:text-5xl">
{title.title} {title.title}
</h1> </h1>
<div className="mt-2 space-y-1.5 text-muted-foreground text-sm md:space-y-0">
{/* Desktop: single row with dot separators */} {/* Desktop: single row with dot separators */}
<div className="hidden md:flex md:items-center md:gap-x-2 md:gap-y-1"> <div className="flex flex-wrap items-center gap-x-3.5 gap-y-2 text-muted-foreground text-sm md:gap-x-5">
<TypeBadge type={title.type} /> <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">
<div className="flex items-center gap-x-2 [&>*+*]:before:mr-2 [&>*+*]:before:text-border [&>*+*]:before:content-['·']"> {title.type === "movie" ? (
{title.contentRating && <span>{title.contentRating}</span>} <>
<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>
)}
{year && <span>{year}</span>} {year && <span>{year}</span>}
{title.genres.length > 0 && ( {title.genres.length > 0 && (
<GenreCollapse genres={title.genres} /> <GenreCollapse genres={title.genres} />
)} )}
{title.voteAverage != null && title.voteAverage > 0 && ( {title.voteAverage != null && title.voteAverage > 0 && (
<span className="inline-flex items-center gap-1 text-primary"> <span className="inline-flex items-center gap-1 text-primary">
<IconStarFilled className="size-3.5" /> <IconStarFilled className="size-3.5 translate-y-[-0.5px]" />
{title.voteAverage.toFixed(1)} {title.voteAverage.toFixed(1)}
</span> </span>
)} )}
{title.status && {title.status &&
!(title.type === "movie" && title.status === "Released") &&
!( !(
title.type === "movie" && title.status === "Released" title.type === "tv" && title.status === "Returning Series"
) && ( ) && (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<StatusIcon status={title.status} /> <StatusIcon status={title.status} />
{title.status} {title.status}
</span> </span>
)} )}
</div>
<a <a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`} href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank" target="_blank"
@@ -146,67 +167,6 @@ export function TitleHero({
<TmdbLogo className="h-2.5 w-auto" /> <TmdbLogo className="h-2.5 w-auto" />
</a> </a>
</div> </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>
)}
</div>
</div> </div>
{title.overview && <ExpandableText text={title.overview} />} {title.overview && <ExpandableText text={title.overview} />}
@@ -245,7 +205,7 @@ function AmbientGlow({ palette }: { palette: ColorPalette | null }) {
{/* Mobile: single full-bleed color wash that flows from the backdrop edge-to-edge */} {/* Mobile: single full-bleed color wash that flows from the backdrop edge-to-edge */}
{glowColor && ( {glowColor && (
<div <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={{ style={{
background: `radial-gradient(ellipse 100% 60% at 50% 0%, ${glowColor}18 0%, transparent 70%)`, 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 */} {/* 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 && ( {palette.vibrant && (
<div <div
className="absolute top-16 -left-32 h-[500px] w-[500px] rounded-full opacity-[0.07] blur-[120px]" 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 { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations"; import { TitleRecommendations } from "./_components/title-recommendations";
import { TitleSeasons } from "./_components/title-seasons"; import { TitleSeasons } from "./_components/title-seasons";
import { TitleTheme } from "./_components/title-theme";
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id)); const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
@@ -68,6 +69,7 @@ export default async function TitleDetailPage({
return ( return (
<div className="relative space-y-10" style={themeStyle}> <div className="relative space-y-10" style={themeStyle}>
<TitleTheme style={themeStyle as Record<string, string>} />
<TitleProvider <TitleProvider
key={title.id} key={title.id}
titleId={title.id} titleId={title.id}
-6
View File
@@ -143,12 +143,6 @@
display: none; 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 */ /* CSS stagger animation — plays immediately from SSR without waiting for JS hydration */
.animate-stagger-item { .animate-stagger-item {
animation: stagger-fade-in 0.35s ease-out both; animation: stagger-fade-in 0.35s ease-out both;
+3 -18
View File
@@ -9,7 +9,7 @@
"@better-auth/drizzle-adapter": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4",
"@player.style/sutro": "0.2.1", "@player.style/sutro": "0.2.1",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.3.2", "@tanstack/react-hotkeys": "0.4.0",
"better-auth": "1.5.4", "better-auth": "1.5.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -17,8 +17,6 @@
"croner": "10.0.2-dev.2", "croner": "10.0.2-dev.2",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "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", "jotai": "2.18.0",
"media-chrome": "4.18.0", "media-chrome": "4.18.0",
"motion": "12.35.1", "motion": "12.35.1",
@@ -27,7 +25,6 @@
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-resizable-panels": "4.7.1",
"recharts": "3.8.0", "recharts": "3.8.0",
"shadcn": "4.0.0", "shadcn": "4.0.0",
"sonner": "2.0.7", "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=="], "@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=="], "@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=="], "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=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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"> <div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-28 rounded-lg" /> <Skeleton className="h-9 w-28 rounded-lg" />
<Skeleton className="h-4 w-px" /> <Skeleton className="h-4 w-px" />
<div className="flex gap-0.5"> <Skeleton className="h-5 w-24 rounded" />
{Array.from({ length: 5 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton
<Skeleton key={i} className="size-5 rounded" />
))}
</div>
</div> </div>
</div> </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 };
+40 -13
View File
@@ -1,30 +1,58 @@
"use client";
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; 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"; import { cn } from "@/lib/utils";
function ScrollArea({ function ScrollArea({
className, className,
children, children,
scrollFade = true,
scrollbarGutter = false,
hideScrollbar = false,
scrollRef,
contentRef,
...props ...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 ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="scroll-area" data-slot="scroll-area"
className={cn("relative", className)} className={cn("relative flex min-h-0 min-w-0 overflow-hidden", className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-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",
)}
>
<ScrollAreaPrimitive.Content
data-slot="scroll-area-content"
ref={contentRef}
> >
{children} {children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> {!hideScrollbar && (
<ScrollAreaPrimitive.Corner /> <>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</>
)}
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
); );
} }
@@ -37,17 +65,16 @@ function ScrollBar({
return ( return (
<ScrollAreaPrimitive.Scrollbar <ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar" data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation} orientation={orientation}
className={cn( 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, className,
)} )}
{...props} {...props}
> >
<ScrollAreaPrimitive.Thumb <ScrollAreaPrimitive.Thumb
data-slot="scroll-area-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> </ScrollAreaPrimitive.Scrollbar>
); );
+1 -4
View File
@@ -22,7 +22,7 @@
"@better-auth/drizzle-adapter": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4",
"@player.style/sutro": "0.2.1", "@player.style/sutro": "0.2.1",
"@tabler/icons-react": "3.40.0", "@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.3.2", "@tanstack/react-hotkeys": "0.4.0",
"better-auth": "1.5.4", "better-auth": "1.5.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -30,8 +30,6 @@
"croner": "10.0.2-dev.2", "croner": "10.0.2-dev.2",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "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", "jotai": "2.18.0",
"media-chrome": "4.18.0", "media-chrome": "4.18.0",
"motion": "12.35.1", "motion": "12.35.1",
@@ -40,7 +38,6 @@
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-resizable-panels": "4.7.1",
"recharts": "3.8.0", "recharts": "3.8.0",
"shadcn": "4.0.0", "shadcn": "4.0.0",
"sonner": "2.0.7", "sonner": "2.0.7",