Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 3 additions & 46 deletions src/components/features/home/PlaylistCarousel.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,12 @@
import { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Playlist } from '../../../types/spotify';
import { HomeSection } from './HomeSection';
import { Skeleton } from '../../ui/skeleton';
import { PlaylistCard } from './PlaylistCard';
import { ScrollArrow } from '../../ui/ScrollArrow';
import { useCarouselScroll } from '../../../hooks/useCarouselScroll';

const CARD_WIDTH = 168;
const SKELETON_KEYS = ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'];

function ScrollArrow({ dir, onClick }: Readonly<{ dir: 'left' | 'right'; onClick: () => void }>) {
const isLeft = dir === 'left';
const Icon = isLeft ? ChevronLeft : ChevronRight;
return (
<div
className={`absolute ${isLeft ? 'left' : 'right'}-0 top-0 bottom-2 w-16 z-10 flex items-center ${isLeft ? 'justify-start' : 'justify-end'} ${isLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l'} from-surface to-transparent pointer-events-none`}
>
<button
aria-label={`Scroll ${dir}`}
onClick={onClick}
className={`pointer-events-auto ${isLeft ? 'ml-1' : 'mr-1'} w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center shadow-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white`}
>
<Icon className="w-5 h-5 text-text-primary" />
</button>
</div>
);
}

interface PlaylistCarouselProps {
title: string;
playlists: Playlist[];
Expand All @@ -45,31 +26,7 @@ export function PlaylistCarousel({
onPlay,
onPause,
}: Readonly<PlaylistCarouselProps>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);

useEffect(() => {
const el = scrollRef.current;
if (!el) return;

const updateArrows = () => {
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};

updateArrows();
el.addEventListener('scroll', updateArrows);
globalThis.addEventListener('resize', updateArrows);
return () => {
el.removeEventListener('scroll', updateArrows);
globalThis.removeEventListener('resize', updateArrows);
};
}, [playlists]);

const scroll = (dir: 'left' | 'right') => {
scrollRef.current?.scrollBy({ left: dir === 'left' ? -CARD_WIDTH * 2 : CARD_WIDTH * 2, behavior: 'smooth' });
};
const { scrollRef, canScrollLeft, canScrollRight, scroll } = useCarouselScroll(playlists);

if (isLoading) {
return (
Expand Down
26 changes: 26 additions & 0 deletions src/components/ui/ScrollArrow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';

interface ScrollArrowProps {
dir: 'left' | 'right';
onClick: () => void;
fromColor?: string;
}

export function ScrollArrow({ dir, onClick, fromColor = 'from-surface' }: Readonly<ScrollArrowProps>) {
const isLeft = dir === 'left';
const Icon = isLeft ? ChevronLeft : ChevronRight;
return (
<div
className={`absolute ${isLeft ? 'left' : 'right'}-0 top-0 bottom-2 w-16 z-10 flex items-center ${isLeft ? 'justify-start' : 'justify-end'} ${isLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l'} ${fromColor} to-transparent pointer-events-none`}
>
<button
aria-label={`Scroll ${dir}`}
onClick={onClick}
type="button"
className={`pointer-events-auto ${isLeft ? 'ml-1' : 'mr-1'} w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center shadow-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white`}
>
<Icon className="w-5 h-5 text-text-primary" />
</button>
</div>
);
}
34 changes: 34 additions & 0 deletions src/hooks/useCarouselScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useRef, useState, useEffect } from 'react';

const CARD_WIDTH = 168;

export function useCarouselScroll(dependency: unknown) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);

useEffect(() => {
const el = scrollRef.current;
if (!el) return;

const updateArrows = () => {
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};

updateArrows();
el.addEventListener('scroll', updateArrows);
globalThis.addEventListener('resize', updateArrows);
return () => {
el.removeEventListener('scroll', updateArrows);
globalThis.removeEventListener('resize', updateArrows);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dependency]);

const scroll = (dir: 'left' | 'right') => {
scrollRef.current?.scrollBy({ left: dir === 'left' ? -CARD_WIDTH * 2 : CARD_WIDTH * 2, behavior: 'smooth' });
};

return { scrollRef, canScrollLeft, canScrollRight, scroll };
}
20 changes: 15 additions & 5 deletions src/pages/ArtistDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@ import { usePlaybackControls } from '../hooks/useSpotifyMutations';
import { ErrorState } from '../components/ui/ErrorState';
import { ArtistDetailSkeleton } from '../components/features/artist/ArtistDetailSkeleton';
import { ArtistAlbumCard } from '../components/features/artist/ArtistAlbumCard';
import { ScrollArrow } from '../components/ui/ScrollArrow';
import { useCarouselScroll } from '../hooks/useCarouselScroll';

export default function ArtistDetail() {
const { id } = useParams<{ id: string }>();

const { data: artist, isLoading: artistLoading, isError: artistError, refetch: refetchArtist } = useArtist(id ?? '');
const { data: albumsData, isLoading: albumsLoading, isError: albumsError, refetch: refetchAlbums } = useArtistAlbums(id ?? '');
const { play } = usePlaybackControls();
const { scrollRef, canScrollLeft, canScrollRight, scroll } = useCarouselScroll(albumsData?.items);

const isLoading = artistLoading || albumsLoading;
const isError = artistError || albumsError;

const albums = (albumsData?.items ?? []).slice(0, 10);
const artistImage = artist?.images?.[0]?.url;

if (isLoading) return <ArtistDetailSkeleton />;
if (isError || !artist) {
return (
Expand All @@ -26,9 +32,6 @@ export default function ArtistDetail() {
);
}

const albums = (albumsData?.items ?? []).slice(0, 10);
const artistImage = artist.images?.[0]?.url;

return (
<div className="flex flex-col min-h-full" data-testid="artist-detail">
{/* Hero section */}
Expand Down Expand Up @@ -73,12 +76,19 @@ export default function ArtistDetail() {
<div className="flex-1 bg-[#121212] px-6 py-6">
<section>
<h2 className="text-text-primary text-2xl font-bold mb-4">Albums</h2>
<div className="overflow-x-auto">
<div className="flex flex-row gap-4 pb-4">
<div className="relative overflow-hidden">
{canScrollLeft && <ScrollArrow dir="left" onClick={() => scroll('left')} fromColor="from-[#121212]" />}
<div
ref={scrollRef}
data-testid="albums-scroll-container"
className="flex gap-4 overflow-x-auto pb-4"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{albums.map((album) => (
<ArtistAlbumCard key={album.id} album={album} />
))}
</div>
{canScrollRight && <ScrollArrow dir="right" onClick={() => scroll('right')} fromColor="from-[#121212]" />}
</div>
</section>
</div>
Expand Down
Loading