- Tags déclaratifs (max 10, 30 chars) via track_tags + tags - Genres normalisés (max 3) via track_genres + taxonomy - GET /api/v1/discover/genre/:genre, tag/:tag (browse chrono) - POST/DELETE follow genre/tag - Section feed "Nouvelles sorties dans vos genres" - Track update: SyncTrackTags, SyncTrackGenres via discover service - Frontend: discoverService, FeedPage by_genres, DiscoverPage - Migration 126_tags_genres_discover - MSW handlers for discover
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
/**
|
|
* Feed Page - v0.10.0 F210
|
|
* Chronological tracks from followed users
|
|
*/
|
|
|
|
import { useEffect, useCallback, useRef } from 'react';
|
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
import { useAudio } from '@/context/AudioContext';
|
|
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 { SuggestionsWidget } from '../components/SuggestionsWidget';
|
|
import { feedService } from '@/services/feedService';
|
|
import { Music2, Loader2 } from 'lucide-react';
|
|
|
|
export function FeedPage() {
|
|
const { playTrack } = useAudio();
|
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
isLoading,
|
|
error,
|
|
refetch,
|
|
} = useInfiniteQuery({
|
|
queryKey: ['feedTracks'],
|
|
queryFn: ({ pageParam }) =>
|
|
feedService.getFeedTracks({
|
|
cursor: pageParam,
|
|
limit: 20,
|
|
by_genres_limit: 10,
|
|
}),
|
|
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
|
initialPageParam: undefined as string | undefined,
|
|
});
|
|
|
|
const tracks = data?.pages.flatMap((p) => p.items) ?? [];
|
|
// v0.10.1 F355: by_genres from first page (section "Nouvelles sorties dans vos genres")
|
|
const byGenres = data?.pages[0]?.by_genres;
|
|
|
|
// Infinite scroll: observe loadMoreRef
|
|
useEffect(() => {
|
|
if (!hasNextPage || isFetchingNextPage) return;
|
|
const el = loadMoreRef.current;
|
|
if (!el) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting) fetchNextPage();
|
|
},
|
|
{ rootMargin: '200px', threshold: 0 },
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
const handlePlay = useCallback(
|
|
(track: { id: string; title: string; artist?: string; duration: number; url: string; cover?: string }) => {
|
|
playTrack?.(track);
|
|
},
|
|
[playTrack],
|
|
);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<ContentFadeIn className="min-h-layout-page pb-24">
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<Music2 className="w-8 h-8 text-primary" />
|
|
<h1 className="text-2xl font-heading font-bold">Feed</h1>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</ContentFadeIn>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<ContentFadeIn className="min-h-layout-page">
|
|
<ErrorDisplay error={error} variant="card" onRetry={() => refetch()} />
|
|
</ContentFadeIn>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ContentFadeIn className="min-h-layout-page pb-24">
|
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_20rem] gap-8">
|
|
<div className="space-y-6 min-w-0">
|
|
<div className="flex items-center gap-3">
|
|
<Music2 className="w-8 h-8 text-primary" />
|
|
<h1 className="text-2xl font-heading font-bold">Feed</h1>
|
|
</div>
|
|
{tracks.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
|
|
<Music2 className="w-16 h-16 text-muted-foreground/50" />
|
|
<p className="text-muted-foreground max-w-sm">
|
|
Suivez des artistes pour voir leurs nouveaux morceaux ici.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{byGenres?.items && byGenres.items.length > 0 && (
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-heading font-semibold">
|
|
Nouvelles sorties dans vos genres
|
|
</h2>
|
|
<TrackGrid
|
|
tracks={byGenres.items}
|
|
emptyMessage=""
|
|
onTrackPlay={handlePlay}
|
|
gap="md"
|
|
/>
|
|
</section>
|
|
)}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-heading font-semibold">
|
|
{byGenres?.items && byGenres.items.length > 0
|
|
? 'Artistes suivis'
|
|
: 'Feed'}
|
|
</h2>
|
|
<TrackGrid
|
|
tracks={tracks}
|
|
emptyMessage="Aucun nouveau morceau"
|
|
onTrackPlay={handlePlay}
|
|
gap="md"
|
|
/>
|
|
</section>
|
|
<div ref={loadMoreRef} className="flex justify-center py-8">
|
|
{isFetchingNextPage && (
|
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<aside className="hidden lg:block">
|
|
<div className="sticky top-24">
|
|
<SuggestionsWidget />
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</ContentFadeIn>
|
|
);
|
|
}
|