veza/apps/web/src/features/discover/pages/DiscoverPage.tsx
senke 559cfbee3e refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion)
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>
2026-04-30 23:30:22 +02:00

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>
);
}