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
6 changes: 3 additions & 3 deletions src/components/features/NowPlaying.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function NowPlaying() {

if (isLoading) {
return (
<aside className="hidden xl:flex flex-col w-60 shrink-0 bg-surface rounded-lg p-4 gap-4">
<aside className="hidden xl:flex flex-col w-72 shrink-0 bg-surface rounded-lg p-4 gap-4">
<Skeleton className="w-full aspect-square rounded" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
Expand All @@ -20,14 +20,14 @@ export function NowPlaying() {

if (!track) {
return (
<aside className="hidden xl:flex flex-col w-60 shrink-0 bg-surface rounded-lg p-4 items-center justify-center">
<aside className="hidden xl:flex flex-col w-72 shrink-0 bg-surface rounded-lg p-4 items-center justify-center">
<p className="text-text-muted text-xs text-center">Nothing playing right now</p>
</aside>
);
}

return (
<aside className="hidden xl:flex flex-col w-60 shrink-0 bg-surface rounded-lg p-4 gap-3">
<aside className="hidden xl:flex flex-col w-72 shrink-0 bg-surface rounded-lg p-4 gap-3">
<img
src={track.album?.images?.[0]?.url}
alt={track.album?.name}
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/home/HomeSection.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { ReactNode } from 'react';

interface HomeSectionProps {
title: string;
title?: string;
children: ReactNode;
}

export function HomeSection({ title, children }: Readonly<HomeSectionProps>) {
return (
<section>
Comment on lines 6 to 10

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HomeSection now allows omitting title but still renders a <section> landmark. A <section> without a heading/accessible name can create unnamed landmarks for screen readers. Consider either keeping title required, or adding an aria-label/aria-labelledby prop (or rendering a <div> when no title is provided).

Suggested change
}
export function HomeSection({ title, children }: Readonly<HomeSectionProps>) {
return (
<section>
'aria-label'?: string;
'aria-labelledby'?: string;
}
export function HomeSection({ title, children, ...sectionProps }: Readonly<HomeSectionProps>) {
const hasAccessibleName =
Boolean(title) ||
Boolean(sectionProps['aria-label']) ||
Boolean(sectionProps['aria-labelledby']);
if (!hasAccessibleName) {
return (
<div>
{title && <h2 className="text-text-primary font-bold text-xl mb-3">{title}</h2>}
{children}
</div>
);
}
return (
<section {...sectionProps}>

Copilot uses AI. Check for mistakes.
<h2 className="text-text-primary font-bold text-xl mb-3">{title}</h2>
{title && <h2 className="text-text-primary font-bold text-xl mb-3">{title}</h2>}
{children}
</section>
);
Expand Down
187 changes: 187 additions & 0 deletions src/components/features/home/MadeForYouSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react';
import { useUserPlaylists, useCurrentUserProfile, useCurrentPlayback } from '../../../hooks/useSpotifyQueries';
import { usePlaybackControls } from '../../../hooks/useSpotifyMutations';
import { usePlayerStore } from '../../../stores/usePlayerStore';
import { HomeSection } from './HomeSection';
import { Skeleton } from '../../ui/skeleton';
import type { Playlist } from '../../../types/spotify';

const CARD_WIDTH = 168;

interface PlaylistCardProps {
playlist: Playlist;
onPlay: (uri: string) => void;
onPause: () => void;
isActive: boolean;
isPlaying: boolean;
}

function PlaylistCard({ playlist, onPlay, onPause, isActive, isPlaying }: Readonly<PlaylistCardProps>) {
const navigate = useNavigate();
const image = playlist.images?.[0]?.url;
const initials = playlist.name.slice(0, 2).toUpperCase();

const handlePlayPause = (e: React.MouseEvent) => {
e.stopPropagation();
if (isActive && isPlaying) {
onPause();
} else {
onPlay(playlist.uri);
}
};

return (
<button
className="flex flex-col gap-2 w-40 shrink-0 text-left group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white rounded-md"
onClick={() => navigate(`/playlist/${playlist.id}`)}
>
<div className="relative w-40 h-40 rounded-md overflow-hidden bg-surface-hover">
{image ? (
<img
src={image}
alt={playlist.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-accent/20 text-accent font-bold text-2xl">
{initials}
</div>
)}

{/* Play/Pause button — bottom right corner */}
<button
aria-label={isActive && isPlaying ? `Pause ${playlist.name}` : `Play ${playlist.name}`}
onClick={handlePlayPause}
className={`absolute bottom-2 right-2 w-9 h-9 rounded-full bg-accent flex items-center justify-center shadow-lg hover:scale-105 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white ${isActive ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0'}`}
>
Comment on lines +35 to +58

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The play/pause control is a clickable <div> inside a clickable <button>. This makes the play/pause action non-focusable/non-keyboard accessible and mixes interactive semantics (nested interactive region). Consider restructuring so the card container is not a <button> (e.g., use a <div>/<Link> for navigation) and render the play/pause as a real <button type="button"> with an accessible label.

Copilot uses AI. Check for mistakes.
{isActive && isPlaying ? (
<>
<span className="group-hover:hidden flex items-end gap-[2px] h-4">
<span className="w-[3px] bg-black rounded-sm animate-eq-bar1" />
<span className="w-[3px] bg-black rounded-sm animate-eq-bar2" />
<span className="w-[3px] bg-black rounded-sm animate-eq-bar3" />
</span>
<Pause className="hidden group-hover:flex w-4 h-4 text-black fill-black" />
</>
) : (
<Play className="w-4 h-4 text-black fill-black ml-0.5" />
)}
</button>
</div>

<div className="px-0.5">
<p className="text-text-primary text-sm font-semibold truncate">{playlist.name}</p>
{playlist.description && (
<p className="text-text-muted text-xs truncate mt-0.5">{playlist.description}</p>
)}
</div>
</button>
);
}

export function MadeForYouSection() {
const { data, isLoading } = useUserPlaylists(50);
const { data: profile } = useCurrentUserProfile();
const { data: playback } = useCurrentPlayback();
const { play, pause } = usePlaybackControls();
const { deviceId } = usePlayerStore();

const sectionTitle = `Made For ${profile?.display_name ?? 'You'}`;
const activeContextUri = playback?.context?.uri ?? null;
const isPlaybackActive = playback?.is_playing ?? false;

Comment on lines +84 to +94

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MadeForYouSection introduces non-trivial UI logic (play/pause behavior tied to playback state + horizontal scrolling/arrow affordances) but has no accompanying tests. Since other home sections are covered by RTL/Vitest tests, adding a MadeForYouSection.test.tsx covering rendering, arrow visibility behavior, and play/pause click behavior would help prevent regressions.

Copilot generated this review using guidance from repository custom instructions.
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);

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

useEffect(() => {
updateArrows();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', updateArrows);
window.addEventListener('resize', updateArrows);
return () => {
el.removeEventListener('scroll', updateArrows);
window.removeEventListener('resize', updateArrows);
};
}, [data]);
Comment on lines +99 to +116

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect registers event listeners using updateArrows, but updateArrows is re-created on every render and isn't included in the dependency list. With eslint-plugin-react-hooks enabled, this will trigger an exhaustive-deps warning and can lead to subtle listener/closure issues later. Consider moving updateArrows inside the effect or wrapping it in useCallback and including it in the deps array.

Copilot uses AI. Check for mistakes.

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

if (isLoading) {
return (
<HomeSection title={sectionTitle}>
<div className="flex gap-4">
{['s1','s2','s3','s4','s5','s6','s7','s8'].map((k) => (
<div key={k} className="flex flex-col gap-2 w-40 shrink-0">
<Skeleton className="w-40 h-40 rounded-md" />
<Skeleton className="h-3 w-3/4 rounded" />
<Skeleton className="h-3 w-1/2 rounded" />
</div>
))}
</div>
</HomeSection>
);
}

const playlists = data?.items ?? [];
if (!playlists.length) return null;

return (
<HomeSection title={sectionTitle}>
<div className="relative overflow-hidden">
{canScrollLeft && (
<div className="absolute left-0 top-0 bottom-2 w-16 z-10 flex items-center justify-start bg-gradient-to-r from-surface to-transparent pointer-events-none">
<button
aria-label="Scroll left"
onClick={() => scroll('left')}
className="pointer-events-auto ml-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"
>
<ChevronLeft className="w-5 h-5 text-text-primary" />
</button>
</div>
)}

<div
ref={scrollRef}
className="flex gap-4 overflow-x-auto pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
Comment on lines +158 to +159

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline scrollbar-hiding styles (scrollbarWidth / msOverflowStyle) only cover Firefox/legacy IE; Chrome/Safari will still show a scrollbar unless ::-webkit-scrollbar is handled. If the intent is to hide the scrollbar cross-browser, consider adding a shared CSS utility (similar to scrollbar-thin in src/index.css) and applying it here instead of inline styles.

Suggested change
className="flex gap-4 overflow-x-auto pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
className="flex gap-4 overflow-x-auto pb-2 scrollbar-thin"

Copilot uses AI. Check for mistakes.
>
{playlists.map((playlist) => (
<PlaylistCard
key={playlist.id}
playlist={playlist}
onPlay={(uri) => play.mutate({ context_uri: uri, device_id: deviceId ?? undefined })}
onPause={() => pause.mutate(deviceId ?? undefined)}
isActive={activeContextUri === playlist.uri}
isPlaying={activeContextUri === playlist.uri && isPlaybackActive}
/>
))}
</div>

{canScrollRight && (
<div className="absolute right-0 top-0 bottom-2 w-16 z-10 flex items-center justify-end bg-gradient-to-l from-surface to-transparent pointer-events-none">
<button
aria-label="Scroll right"
onClick={() => scroll('right')}
className="pointer-events-auto 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"
>
<ChevronRight className="w-5 h-5 text-text-primary" />
</button>
</div>
)}
</div>
</HomeSection>
);
}
2 changes: 1 addition & 1 deletion src/components/features/home/NavHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<div
role="radiogroup"
aria-label="Content filter"
className="sticky top-0 z-10 bg-surface px-4 py-2 flex items-center gap-2"
className="sticky top-0 z-10 bg-surface px-4 py-2 mt-4 flex items-center gap-2"
>
{CHIPS.map((chip, i) => (
<button
Expand All @@ -55,7 +55,7 @@
);
}

export function useNavHeader() {

Check warning on line 58 in src/components/features/home/NavHeader.tsx

View workflow job for this annotation

GitHub Actions / eslint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const [activeFilter, setActiveFilter] = useState<FilterChip>("All");
return { activeFilter, setActiveFilter };
}
16 changes: 8 additions & 8 deletions src/components/features/home/RecentlyPlayedCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ vi.mock('react-router-dom', async () => {
});

const albumItem: RecentItem = {
id: 'album1',
id: 'track1',
name: 'Test Album',
imageUrl: 'https://img.test/cover.jpg',
type: 'album',
uri: 'spotify:album:album1',
type: 'track',
uri: 'spotify:track:track1',
navigationPath: '/album/album1',
subtitle: 'Test Artist',
};

const artistItem: RecentItem = {
id: 'artist1',
id: 'track2',
name: 'Test Artist',
imageUrl: undefined,
type: 'artist',
uri: 'spotify:artist:artist1',
navigationPath: '/artist/artist1',
type: 'track',
uri: 'spotify:track:track2',
navigationPath: '/album/album2',
subtitle: 'Artist',
};

Expand Down Expand Up @@ -66,7 +66,7 @@ describe('RecentlyPlayedCard', () => {
const onPlay = vi.fn();
renderCard(albumItem, onPlay);
fireEvent.click(screen.getByRole('button', { name: /play/i }));
expect(onPlay).toHaveBeenCalledWith('spotify:album:album1');
expect(onPlay).toHaveBeenCalledWith('spotify:track:track1');
});

it('does not navigate when play button is clicked', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/features/home/RecentlyPlayedCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type RecentItem = {
id: string;
name: string;
imageUrl?: string;
type: 'album' | 'artist';
type: 'track';
uri: string;
navigationPath: string;
subtitle: string;
Expand Down
32 changes: 15 additions & 17 deletions src/components/features/home/RecentlyPlayedSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,42 +103,40 @@ describe('RecentlyPlayedSection', () => {
data: { items: [makeTrack('t1', 'a1', 'ar1')], cursors: {}, href: '', limit: 20 },
} as never);
render(<RecentlyPlayedSection />, { wrapper });
expect(screen.getByText('Recently Played')).toBeInTheDocument();
expect(screen.getByText('Album a1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /play artist ar1/i })).toBeInTheDocument();
expect(screen.queryByText('Recently Played')).not.toBeInTheDocument();
expect(screen.getByText('Track t1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /play Track t1/i })).toBeInTheDocument();
});

it('deduplicates albums across tracks', () => {
it('deduplicates tracks with the same id', () => {
const items = [
makeTrack('t1', 'same-album', 'ar1'),
makeTrack('t2', 'same-album', 'ar2'),
makeTrack('same-track', 'al1', 'ar1'),
makeTrack('same-track', 'al1', 'ar1'),
];
vi.mocked(useRecentlyPlayed).mockReturnValue({
isLoading: false,
data: { items, cursors: {}, href: '', limit: 20 },
} as never);
render(<RecentlyPlayedSection />, { wrapper });
// 'Album same-album' should appear only once
expect(screen.getAllByText('Album same-album')).toHaveLength(1);
expect(screen.getAllByText('Track same-track')).toHaveLength(1);
});

it('deduplicates artists across tracks', () => {
it('shows multiple tracks from the same album', () => {
const items = [
makeTrack('t1', 'al1', 'same-artist'),
makeTrack('t2', 'al2', 'same-artist'),
makeTrack('t1', 'same-album', 'ar1'),
makeTrack('t2', 'same-album', 'ar2'),
];
vi.mocked(useRecentlyPlayed).mockReturnValue({
isLoading: false,
data: { items, cursors: {}, href: '', limit: 20 },
} as never);
render(<RecentlyPlayedSection />, { wrapper });
// The artist name may appear in album subtitles too; verify the artist card is deduplicated
expect(screen.getAllByRole('button', { name: /play artist same-artist/i })).toHaveLength(1);
expect(screen.getByText('Track t1')).toBeInTheDocument();
expect(screen.getByText('Track t2')).toBeInTheDocument();
});

it('caps output at 8 items', () => {
// 6 unique tracks, each with unique album+artist → up to 12 items, capped at 8
const items = Array.from({ length: 6 }, (_, i) =>
const items = Array.from({ length: 10 }, (_, i) =>
makeTrack(`t${i}`, `al${i}`, `ar${i}`)
);
vi.mocked(useRecentlyPlayed).mockReturnValue({
Expand All @@ -150,14 +148,14 @@ describe('RecentlyPlayedSection', () => {
expect(playButtons).toHaveLength(8);
});

it('still emits artist when track.album is absent', () => {
it('still renders track when album is absent', () => {
const trackNoAlbum = makeTrack('t1', 'al1', 'ar1');
(trackNoAlbum.track as { album?: unknown }).album = undefined;
vi.mocked(useRecentlyPlayed).mockReturnValue({
isLoading: false,
data: { items: [trackNoAlbum], cursors: {}, href: '', limit: 20 },
} as never);
render(<RecentlyPlayedSection />, { wrapper });
expect(screen.getByText('Artist ar1')).toBeInTheDocument();
expect(screen.getByText('Track t1')).toBeInTheDocument();
});
});
Loading
Loading