mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -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 {
|
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,45 +143,40 @@ 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",
|
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
||||||
}}
|
{items.slice(0, 20).map((item, i) => (
|
||||||
className="carousel-tilt -mx-6 sm:-mx-2"
|
<div
|
||||||
>
|
key={`${item.type}-${item.tmdbId}`}
|
||||||
<CarouselContent className="px-6 sm:px-2">
|
className="w-[140px] shrink-0 sm:w-[160px]"
|
||||||
{items.slice(0, 20).map((item, i) => (
|
>
|
||||||
<CarouselItem
|
<div
|
||||||
key={`${item.type}-${item.tmdbId}`}
|
className="animate-stagger-item"
|
||||||
className="w-[140px] shrink-0 basis-auto pl-4 sm:w-[160px]"
|
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div
|
<TitleCard
|
||||||
className="animate-stagger-item"
|
tmdbId={item.tmdbId}
|
||||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
type={item.type}
|
||||||
>
|
title={item.title}
|
||||||
<TitleCard
|
posterPath={item.posterPath}
|
||||||
tmdbId={item.tmdbId}
|
releaseDate={item.releaseDate}
|
||||||
type={item.type}
|
voteAverage={item.voteAverage}
|
||||||
title={item.title}
|
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
||||||
posterPath={item.posterPath}
|
episodeProgress={
|
||||||
releaseDate={item.releaseDate}
|
episodeProgress[`${item.tmdbId}-${item.type}`]
|
||||||
voteAverage={item.voteAverage}
|
}
|
||||||
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
/>
|
||||||
episodeProgress={
|
</div>
|
||||||
episodeProgress[`${item.tmdbId}-${item.type}`]
|
</div>
|
||||||
}
|
))}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,106 +107,65 @@ 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="flex flex-wrap items-center gap-x-3.5 gap-y-2 text-muted-foreground text-sm md:gap-x-5">
|
||||||
<div className="hidden md:flex md:items-center md:gap-x-2 md:gap-y-1">
|
<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">
|
||||||
<TypeBadge type={title.type} />
|
{title.type === "movie" ? (
|
||||||
<div className="flex items-center gap-x-2 [&>*+*]:before:mr-2 [&>*+*]:before:text-border [&>*+*]:before:content-['·']">
|
<>
|
||||||
{title.contentRating && <span>{title.contentRating}</span>}
|
<IconMovie
|
||||||
{year && <span>{year}</span>}
|
aria-hidden
|
||||||
{title.genres.length > 0 && (
|
className="size-3.5 translate-y-[-0.5px]"
|
||||||
<GenreCollapse genres={title.genres} />
|
/>
|
||||||
)}
|
<span className="hidden md:inline">Movie</span>
|
||||||
{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)}
|
<IconDeviceTv
|
||||||
</span>
|
aria-hidden
|
||||||
)}
|
className="size-3.5 translate-y-[-0.5px]"
|
||||||
{title.status &&
|
/>
|
||||||
!(
|
<span className="hidden md:inline">TV</span>
|
||||||
title.type === "movie" && title.status === "Released"
|
</>
|
||||||
) && (
|
)}
|
||||||
<span className="inline-flex items-center gap-1">
|
</div>
|
||||||
<StatusIcon status={title.status} />
|
{title.contentRating && (
|
||||||
{title.status}
|
<div className="inline-flex border border-muted-foreground/50 px-1.5 font-medium text-[13px]">
|
||||||
</span>
|
{title.contentRating}
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
</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 */}
|
{/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
<ScrollAreaPrimitive.Content
|
||||||
|
data-slot="scroll-area-content"
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
{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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user