veza/apps/web/src/features/feed/pages/FeedPage.tsx
senke 4a422fc4c3 feat(v0.10.1): Tags & Genres discover - F351-F355
- 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
2026-03-09 01:52:56 +01:00

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