mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user