Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS
errors, 0 behaviour change beyond one incidental auth bugfix
flagged below.
storybook/no-redundant-story-name (23 → 0) — 14 stories files
Storybook v7+ infers the story name from the variable name, so
`name: 'Default'` next to `export const Default: Story = …` is
pure noise. Removed only when the name was redundant ;
preserved when the label was a French translation
('Par défaut', 'Chargement', 'Avec erreur', etc.) since those
are intentional.
react-refresh/only-export-components (25 → 0) — 21 files
Each warning marks a file that exports a React component AND a
hook / context / constant / barrel re-export. Suppressed
per-line with the suppression-with-justification pattern :
// eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API
The justification matters — every comment names the specific
thing being co-located (hook / context / CVA constant / lazy
registry / route config / test util / backward-compat barrel).
Splitting these would create 21 new files for a HMR-only DX
win that's already a non-issue in practice.
@typescript-eslint/no-non-null-assertion (139 → 0) — 43 files
Distribution of fixes :
~85 cases : refactored to explicit guard
`if (!x) throw new Error('invariant: …')`
or hoisted into local with narrowing.
~36 cases : helper extraction (one tooltip test had 16
`wrapper!` patterns reduced to a single
`getWrapper()` helper).
~18 cases : suppressed with specific reason :
static literal arrays where index is provably
in bounds, mock fixtures with structural
guarantees, filter-then-map patterns where the
filter excludes the null branch.
One incidental find : services/api/auth.ts threw on missing
tokens but didn't guard `user` ; added the missing check while
refactoring the `user!` to a guard.
baseline post-commit : 921 warnings, 0 errors, 0 TS errors.
The remaining buckets are no-restricted-syntax (757, design-system
guardrail), no-explicit-any (115), exhaustive-deps (49).
CI --max-warnings will be lowered to 921 in the follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
/**
|
|
* Discover Page - v0.10.1 F351-F355
|
|
* Browse tracks by genre or tag
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|
import { usePlayerStore } from '@/features/player/store/playerStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
|
|
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
|
|
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
|
|
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
|
import { discoverService, type Genre } from '@/services/discoverService';
|
|
import { PlaylistCard } from '@/features/playlists/components/PlaylistCard';
|
|
import { PlaylistCardSkeleton } from '@/features/playlists/components/PlaylistCardSkeleton';
|
|
import { Music2, Loader2, ChevronLeft, Compass } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// Spotify-style genre gradient colors — rich, two-tone gradients
|
|
const GENRE_GRADIENTS = [
|
|
'from-[#e13300] to-[#ff6b3d]',
|
|
'from-[#8400e7] to-[#b44dff]',
|
|
'from-[#1e3264] to-[#3d5a9e]',
|
|
'from-[#e8115b] to-[#ff4d8d]',
|
|
'from-[#148a08] to-[#2cc41a]',
|
|
'from-[#e91429] to-[#ff5c6e]',
|
|
'from-[#477d95] to-[#6ab0cc]',
|
|
'from-[#8c67ab] to-[#b896d6]',
|
|
'from-[#ba5d07] to-[#e89234]',
|
|
'from-[#1e3264] to-[#608108]',
|
|
'from-[#dc148c] to-[#ff4db8]',
|
|
'from-[#186962] to-[#2aaa9e]',
|
|
'from-[#7358ff] to-[#a18dff]',
|
|
'from-[#c84040] to-[#e87070]',
|
|
'from-[#0d73ec] to-[#4da3ff]',
|
|
];
|
|
|
|
export function DiscoverPage() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const genreFromQuery = searchParams.get('genre');
|
|
const tagFromQuery = searchParams.get('tag');
|
|
const play = usePlayerStore((s) => s.play);
|
|
const navigate = useNavigate();
|
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
const { t } = useTranslation();
|
|
|
|
const browseGenre = genreFromQuery;
|
|
const browseTag = tagFromQuery;
|
|
|
|
const { data: genres, isLoading: genresLoading } = useQuery({
|
|
queryKey: ['discoverGenres'],
|
|
queryFn: () => discoverService.getGenres(),
|
|
});
|
|
|
|
const { data: editorialData, isLoading: editorialLoading } = useQuery({
|
|
queryKey: ['discoverEditorialPlaylists'],
|
|
queryFn: () => discoverService.getEditorialPlaylists({ limit: 20 }),
|
|
});
|
|
|
|
const {
|
|
data: genreTracksData,
|
|
fetchNextPage: fetchNextGenre,
|
|
hasNextPage: hasNextGenre,
|
|
isFetchingNextPage: isFetchingGenre,
|
|
isLoading: genreTracksLoading,
|
|
error: genreTracksError,
|
|
refetch: refetchGenre,
|
|
} = useInfiniteQuery({
|
|
queryKey: ['discoverGenre', browseGenre],
|
|
queryFn: ({ pageParam }) => {
|
|
if (!browseGenre) throw new Error('browseGenre required');
|
|
return discoverService.getTracksByGenre(browseGenre, {
|
|
cursor: pageParam,
|
|
limit: 20,
|
|
});
|
|
},
|
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
|
initialPageParam: undefined as string | undefined,
|
|
enabled: !!browseGenre,
|
|
});
|
|
|
|
const {
|
|
data: tagTracksData,
|
|
fetchNextPage: fetchNextTag,
|
|
hasNextPage: hasNextTag,
|
|
isFetchingNextPage: isFetchingTag,
|
|
isLoading: tagTracksLoading,
|
|
error: tagTracksError,
|
|
refetch: refetchTag,
|
|
} = useInfiniteQuery({
|
|
queryKey: ['discoverTag', browseTag],
|
|
queryFn: ({ pageParam }) => {
|
|
if (!browseTag) throw new Error('browseTag required');
|
|
return discoverService.getTracksByTag(browseTag, {
|
|
cursor: pageParam,
|
|
limit: 20,
|
|
});
|
|
},
|
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
|
initialPageParam: undefined as string | undefined,
|
|
enabled: !!browseTag,
|
|
});
|
|
|
|
const genreTracks = genreTracksData?.pages.flatMap((p) => p.items) ?? [];
|
|
const tagTracks = tagTracksData?.pages.flatMap((p) => p.items) ?? [];
|
|
const tracks = browseGenre ? genreTracks : browseTag ? tagTracks : [];
|
|
const isLoadingTracks = browseGenre ? genreTracksLoading : tagTracksLoading;
|
|
const error = browseGenre ? genreTracksError : tagTracksError;
|
|
const hasNext = browseGenre ? hasNextGenre : hasNextTag;
|
|
const isFetchingNext = browseGenre ? isFetchingGenre : isFetchingTag;
|
|
const fetchNext = browseGenre ? fetchNextGenre : fetchNextTag;
|
|
const refetch = browseGenre ? refetchGenre : refetchTag;
|
|
|
|
useEffect(() => {
|
|
if (!hasNext || isFetchingNext) return;
|
|
const el = loadMoreRef.current;
|
|
if (!el) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting) fetchNext();
|
|
},
|
|
{ rootMargin: '200px', threshold: 0 }
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [hasNext, isFetchingNext, fetchNext]);
|
|
|
|
const handlePlay = useCallback(
|
|
(track: { id: string; title: string; artist?: string; duration: number; url: string; cover?: string }) => {
|
|
play(track);
|
|
},
|
|
[play]
|
|
);
|
|
|
|
const handleTrackClick = useCallback(
|
|
(track: { id: string }) => {
|
|
navigate(`/tracks/${track.id}`);
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
const handleGenreClick = (genre: Genre) => {
|
|
setSearchParams({ genre: genre.slug });
|
|
};
|
|
|
|
const goBack = () => {
|
|
setSearchParams({});
|
|
};
|
|
|
|
const showGenreList = !browseGenre && !browseTag;
|
|
|
|
if (genresLoading && showGenreList) {
|
|
return (
|
|
<ContentFadeIn className="min-h-layout-page pb-36">
|
|
<div className="flex items-center gap-3">
|
|
<Music2 className="w-8 h-8 text-primary" />
|
|
<h1 className="text-2xl font-heading font-bold">{t('discover.title')}</h1>
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mt-6">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="min-h-[7.5rem] rounded-2xl bg-muted/40 animate-pulse"
|
|
style={{ animationDelay: `${i * 50}ms` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</ContentFadeIn>
|
|
);
|
|
}
|
|
|
|
const hasEditorialPlaylists = editorialData?.items && editorialData.items.length > 0;
|
|
|
|
return (
|
|
<ContentFadeIn className="min-h-layout-page pb-36">
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
{browseGenre || browseTag ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={goBack}
|
|
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
|
|
aria-label={t('discover.back')}
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
{t('discover.back')}
|
|
</Button>
|
|
) : null}
|
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
|
|
<Compass className="w-6 h-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-heading font-bold tracking-tight">
|
|
{browseGenre
|
|
? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre
|
|
: browseTag
|
|
? browseTag
|
|
: t('discover.title')}
|
|
</h1>
|
|
{showGenreList && (
|
|
<p className="text-sm text-muted-foreground mt-0.5">{t('discover.subtitle')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showGenreList && genres ? (
|
|
<section className="space-y-4" aria-label={t('discover.byGenre')}>
|
|
<h2 className="text-lg font-heading font-semibold tracking-tight">
|
|
{t('discover.byGenre')}
|
|
</h2>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
|
{genres.map((g, i) => (
|
|
<button
|
|
key={g.slug}
|
|
onClick={() => handleGenreClick(g)}
|
|
aria-label={t('discover.browseGenre', { genre: g.name })}
|
|
className={cn(
|
|
'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left',
|
|
'bg-gradient-to-br',
|
|
GENRE_GRADIENTS[i % GENRE_GRADIENTS.length],
|
|
'hover:scale-[1.04] hover:shadow-xl hover:shadow-black/20 active:scale-[0.97]',
|
|
'transition-all duration-[var(--sumi-duration-normal)] ease-[var(--sumi-ease-out)]',
|
|
'group animate-card-enter',
|
|
)}
|
|
style={{ animationDelay: `${i * 40}ms` }}
|
|
>
|
|
<span className="relative z-10 font-heading font-bold text-white text-base drop-shadow-md">
|
|
{g.name}
|
|
</span>
|
|
{'count' in g && (g as Genre & { count?: number }).count != null && (
|
|
<span className="relative z-10 block mt-1 text-xs text-white/60 font-medium">
|
|
{t('discover.trackCount', { count: (g as Genre & { count?: number }).count })}
|
|
</span>
|
|
)}
|
|
{/* Decorative circles */}
|
|
<div className="absolute -bottom-3 -right-3 w-24 h-24 rounded-full bg-white/10 rotate-12 group-hover:scale-125 transition-transform duration-500 ease-out" />
|
|
<div className="absolute -top-4 -right-6 w-16 h-16 rounded-full bg-white/[0.07] group-hover:scale-110 transition-transform duration-700" />
|
|
{/* Bottom highlight */}
|
|
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/15 to-transparent pointer-events-none" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{showGenreList ? (
|
|
<section className="space-y-4" aria-label={t('discover.editorialPlaylists')}>
|
|
<div className="section-divider my-2" />
|
|
<h2 className="text-lg font-heading font-semibold tracking-tight">
|
|
{t('discover.editorialPlaylists')}
|
|
</h2>
|
|
{editorialLoading ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<PlaylistCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : hasEditorialPlaylists ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{editorialData.items.map((pl) => (
|
|
<PlaylistCard
|
|
key={pl.id}
|
|
playlist={{
|
|
id: pl.id,
|
|
user_id: pl.user?.id ?? '',
|
|
title: pl.title,
|
|
description: pl.description,
|
|
is_public: true,
|
|
track_count: pl.track_count,
|
|
cover_url: pl.cover_url,
|
|
created_at: '',
|
|
updated_at: '',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground py-4">
|
|
{t('discover.noEditorialPlaylists')}
|
|
</p>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
{browseGenre || browseTag ? (
|
|
<>
|
|
{isLoadingTracks ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<TrackCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<ErrorDisplay error={error} variant="card" onRetry={() => { refetch(); }} />
|
|
) : (
|
|
<>
|
|
<TrackGrid
|
|
tracks={tracks}
|
|
emptyMessage={t('discover.noTracksInGenre')}
|
|
onTrackPlay={handlePlay}
|
|
onTrackClick={handleTrackClick}
|
|
gap="md"
|
|
/>
|
|
<div ref={loadMoreRef} className="flex justify-center py-8">
|
|
{isFetchingNext && (
|
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</ContentFadeIn>
|
|
);
|
|
}
|