Add series browsing, search, and detail UI

Introduce full support for Audible series exploration: API routes, frontend pages, components, hooks, and integrations. Key changes:

- Prisma: add Audiobook.seriesAsin for linking audiobooks to series detail pages.
- Backend: add /api/series/search and /api/series/[asin] routes that require auth; scrape Audible series data and enrich books with library availability.
- Integrations/services: add audible-series integration and update request/HTTP services to support the workflow.
- Frontend: add /series and /series/[asin] pages, new components (SeriesCard, SeriesGrid, SeriesDetailCard, SimilarSeriesRow) and wire them to a new useSeries hook; update AudiobookDetailsModal to show/link series; add Series link to Header.
- Misc: extend audiobook types with series fields and add seriesLabels to language-config for scraping.

These changes enable users to search for series, view series metadata and books, and navigate between audiobook and series detail pages.
This commit is contained in:
kikootwo
2026-02-20 10:19:30 -05:00
parent 5d8ac2f73d
commit cb9f1b81bc
17 changed files with 1663 additions and 1 deletions
+153
View File
@@ -0,0 +1,153 @@
/**
* Component: Series Card
* Documentation: documentation/frontend/components.md
*
* Premium "Cover First" design - metadata integrated into the cover overlay.
* Rating badge top-left, book count top-right, tags in bottom gradient overlay.
* Only the title lives below the cover, ensuring consistent row heights in the grid.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SeriesSummary } from '@/lib/hooks/useSeries';
interface SeriesCardProps {
series: SeriesSummary;
squareCovers?: boolean;
}
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
const visibleTags = series.tags.slice(0, 2);
const hasTags = visibleTags.length > 0;
const hasRating = series.rating != null && series.rating > 0;
return (
<Link
href={`/series/${series.asin}`}
className="group outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded-2xl block"
aria-label={`View ${series.title} series`}
>
{/* Cover Container — The Hero */}
<div
className={`
relative overflow-hidden rounded-xl
w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'}
shadow-lg shadow-black/20 dark:shadow-black/40
group-hover:shadow-xl group-hover:shadow-black/30 dark:group-hover:shadow-black/55
transform group-hover:scale-[1.02] group-hover:-translate-y-0.5
transition-all duration-300 ease-out
`}
>
{/* Cover Art or Fallback */}
{series.coverArtUrl ? (
<Image
src={series.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
<svg
className="w-1/3 h-1/3 text-white/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
</div>
)}
{/* Top-row badges — Rating (left) + Book count (right) */}
{/* Rating Badge — top-left, matches AudiobookCard pattern exactly */}
{hasRating && (
<div className="
absolute top-2.5 left-2.5
flex items-center gap-1 px-2 py-1
rounded-lg bg-black/50 backdrop-blur-md
text-white text-xs font-medium
transition-opacity duration-300 group-hover:opacity-0
">
<svg className="w-3.5 h-3.5 text-amber-400 shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{series.rating!.toFixed(1)}</span>
</div>
)}
{/* Book count badge — top-right */}
{series.bookCount > 0 && (
<div className="
absolute top-2.5 right-2.5
px-2 py-1
text-[11px] font-bold rounded-lg
bg-black/50 backdrop-blur-md
text-white
transition-opacity duration-300 group-hover:opacity-0
">
{series.bookCount} {series.bookCount === 1 ? 'Book' : 'Books'}
</div>
)}
{/* Bottom gradient overlay — always present, deepens on hover */}
<div className={`
absolute inset-x-0 bottom-0
transition-all duration-300
${hasTags
? 'h-20 bg-gradient-to-t from-black/75 via-black/30 to-transparent group-hover:h-24 group-hover:from-black/85'
: 'h-10 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100'
}
`} />
{/* Tag pills — pinned to bottom of cover, inside gradient */}
{hasTags && (
<div className="
absolute inset-x-0 bottom-0
flex items-end gap-1.5 p-2.5
pointer-events-none
">
{visibleTags.map(tag => (
<span
key={tag}
className="
inline-block px-2.5 py-0.5
text-[10px] font-medium
rounded-full
bg-black/30 backdrop-blur-md
text-white/90
ring-1 ring-white/15
transition-opacity duration-300
"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Below-cover: title only — fixed, predictable height across all cards */}
<div className="mt-2.5 px-0.5">
<h3 className="
font-semibold text-[14px] leading-snug
text-gray-900 dark:text-gray-100
line-clamp-2
group-hover:text-emerald-600 dark:group-hover:text-emerald-400
transition-colors duration-200
">
{series.title}
</h3>
</div>
</Link>
);
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Component: Series Detail Card
* Documentation: documentation/frontend/components.md
*
* Hero section for the series detail page with rectangular cover image,
* title, book count, rating, collapsible description, and tag pills.
*/
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { SeriesDetail } from '@/lib/hooks/useSeries';
interface SeriesDetailCardProps {
series: SeriesDetail;
squareCovers?: boolean;
}
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
const [expanded, setExpanded] = useState(false);
const hasLongDescription = (series.description?.length || 0) > 300;
return (
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Rectangular Cover */}
<div className="flex-shrink-0">
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
{series.books[0]?.coverArtUrl ? (
<Image
src={series.books[0].coverArtUrl}
alt={series.title}
fill
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
)}
</div>
</div>
{/* Series Info */}
<div className="flex-1 min-w-0 text-center sm:text-left">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
{series.title}
</h1>
{/* Meta row: book count + rating */}
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
{series.bookCount > 0 && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
{series.bookCount} Book{series.bookCount !== 1 ? 's' : ''}
</span>
)}
{series.rating != null && series.rating > 0 && (
<span className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{series.rating.toFixed(1)}
{series.ratingCount != null && series.ratingCount > 0 && (
<span className="text-gray-400 dark:text-gray-500">
({series.ratingCount.toLocaleString()})
</span>
)}
</span>
)}
</div>
{/* Tag Pills */}
{series.tags.length > 0 && (
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
{series.tags.map(tag => (
<span
key={tag}
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
>
{tag}
</span>
))}
</div>
)}
{/* Audible Link */}
{series.audibleUrl && (
<a
href={series.audibleUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
View on Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{/* Description */}
{series.description && (
<div className="mt-4">
<p
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
!expanded && hasLongDescription ? 'line-clamp-4' : ''
}`}
>
{series.description}
</p>
{hasLongDescription && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
)}
</div>
</div>
);
}
export function SeriesDetailSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
return (
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
{/* Cover skeleton */}
<div className="flex-shrink-0">
<div className={`w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
</div>
{/* Info skeleton */}
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-7 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-7 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="flex gap-2 justify-center sm:justify-start">
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
</div>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Component: Series Grid
* Documentation: documentation/frontend/components.md
*
* Grid layout for series cards with loading skeletons and empty state.
* Uses the same responsive column system as AudiobookGrid since
* series cards use rectangular (2:3) aspect ratios like book covers.
*/
'use client';
import React from 'react';
import { SeriesCard } from './SeriesCard';
import { SeriesSummary } from '@/lib/hooks/useSeries';
interface SeriesGridProps {
series: SeriesSummary[];
isLoading?: boolean;
emptyMessage?: string;
cardSize?: number;
squareCovers?: boolean;
}
function getGridClasses(size: number): string {
const sizeMap: Record<number, string> = {
1: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
2: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
3: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
7: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
8: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
9: 'grid-cols-1 sm:grid-cols-2',
};
return sizeMap[size] || sizeMap[5];
}
export function SeriesGrid({
series,
isLoading = false,
emptyMessage = 'No series found',
cardSize = 5,
squareCovers = false,
}: SeriesGridProps) {
const gridClasses = getGridClasses(cardSize);
if (isLoading) {
return (
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
{Array.from({ length: 10 }).map((_, i) => (
<SeriesSkeletonCard key={i} index={i} squareCovers={squareCovers} />
))}
</div>
);
}
if (series.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid ${gridClasses} gap-4 sm:gap-5 lg:gap-6`}>
{series.map(s => (
<SeriesCard key={s.asin} series={s} squareCovers={squareCovers} />
))}
</div>
);
}
function SeriesSkeletonCard({ index = 0, squareCovers = false }: { index?: number; squareCovers?: boolean }) {
return (
<div
className="animate-pulse"
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Rectangular cover skeleton */}
<div className={`relative overflow-hidden rounded-xl w-full ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800`}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
{/* Text skeleton */}
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Component: Similar Series Row
* Documentation: documentation/frontend/components.md
*
* Horizontal scrollable carousel of similar series cards.
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
* Each card navigates to the series detail page.
*/
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { SimilarSeries } from '@/lib/hooks/useSeries';
interface SimilarSeriesRowProps {
series: SimilarSeries[];
currentSeriesTitle?: string;
squareCovers?: boolean;
}
export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = false }: SimilarSeriesRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
}, []);
useEffect(() => {
checkScroll();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', checkScroll, { passive: true });
const observer = new ResizeObserver(checkScroll);
observer.observe(el);
return () => {
el.removeEventListener('scroll', checkScroll);
observer.disconnect();
};
}, [checkScroll, series]);
const scroll = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const scrollAmount = el.clientWidth * 0.7;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
};
if (series.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
Similar Series
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({series.length})
</span>
</div>
<div className="relative group">
{/* Left arrow */}
{canScrollLeft && (
<button
onClick={() => scroll('left')}
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Scrollable row */}
<div
ref={scrollRef}
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{series.map(s => (
<Link
key={s.asin}
href={`/series/${s.asin}${currentSeriesTitle ? `?from=${encodeURIComponent(currentSeriesTitle)}` : ''}`}
className="flex-shrink-0 w-20 sm:w-24 group/card outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-xl"
>
{/* Cover */}
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
{s.coverArtUrl ? (
<Image
src={s.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="96px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
{s.title.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Title */}
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-emerald-600 dark:group-hover/card:text-emerald-400 transition-colors">
{s.title}
</p>
</Link>
))}
</div>
{/* Right arrow */}
{canScrollRight && (
<button
onClick={() => scroll('right')}
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
aria-label="Scroll right"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Fade edges */}
{canScrollLeft && (
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
{canScrollRight && (
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
)}
</div>
</div>
);
}
export function SimilarSeriesSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
return (
<div className="space-y-3 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
<div className="flex gap-4 sm:gap-5 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-20 sm:w-24" style={{ animationDelay: `${i * 50}ms` }}>
<div className={`w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden`}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
</div>
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
</div>
))}
</div>
</div>
);
}