147 lines
4.2 KiB
TypeScript
147 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
|
|
import { ContentCard } from "@/components/content-card";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { useAudio } from "@/providers/audio-provider";
|
|
import type { Content, PaginatedResponse } from "@/types/content";
|
|
|
|
interface ContentListProps {
|
|
fetchFn: (params: {
|
|
limit: number;
|
|
offset: number;
|
|
}) => Promise<PaginatedResponse<Content>>;
|
|
title?: string;
|
|
}
|
|
|
|
export function ContentList({ fetchFn, title }: ContentListProps) {
|
|
const { setActiveVideo } = useAudio();
|
|
const [contents, setContents] = React.useState<Content[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [offset, setOffset] = React.useState(0);
|
|
const [hasMore, setHasMore] = React.useState(true);
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: On a besoin de contents pour ré-attacher l'observer quand la liste change
|
|
React.useEffect(() => {
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
// On cherche l'entrée avec le plus grand ratio d'intersection parmi celles qui dépassent le seuil
|
|
let bestEntry: IntersectionObserverEntry | null = null;
|
|
let maxRatio = 0;
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
|
maxRatio = entry.intersectionRatio;
|
|
bestEntry = entry;
|
|
}
|
|
}
|
|
|
|
if (bestEntry && maxRatio >= 0.6) {
|
|
const contentId = bestEntry.target.getAttribute("data-content-id");
|
|
if (contentId) {
|
|
setActiveVideo(contentId);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1.0], // Plusieurs seuils pour une détection plus fine du "meilleur"
|
|
root: containerRef.current,
|
|
},
|
|
);
|
|
|
|
const elements = containerRef.current?.querySelectorAll("[data-content-id]");
|
|
elements?.forEach((el) => {
|
|
observer.observe(el);
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
}, [setActiveVideo, contents]);
|
|
|
|
const fetchInitial = React.useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetchFn({
|
|
limit: 10,
|
|
offset: 0,
|
|
});
|
|
setContents(response.data);
|
|
setOffset(0);
|
|
setHasMore(response.data.length === 10);
|
|
} catch (error) {
|
|
console.error("Failed to fetch contents:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [fetchFn]);
|
|
|
|
React.useEffect(() => {
|
|
fetchInitial();
|
|
}, [fetchInitial]);
|
|
|
|
const loadMore = React.useCallback(async () => {
|
|
if (!hasMore || loading) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetchFn({
|
|
limit: 10,
|
|
offset: offset + 10,
|
|
});
|
|
|
|
setContents((prev) => {
|
|
const newIds = new Set(response.data.map((item) => item.id));
|
|
const filteredPrev = prev.filter((item) => !newIds.has(item.id));
|
|
return [...filteredPrev, ...response.data];
|
|
});
|
|
setOffset((prev) => prev + 10);
|
|
setHasMore(response.data.length === 10);
|
|
} catch (error) {
|
|
console.error("Failed to load more contents:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [offset, hasMore, loading, fetchFn]);
|
|
|
|
const { loaderRef } = useInfiniteScroll({
|
|
hasMore,
|
|
loading,
|
|
onLoadMore: loadMore,
|
|
});
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="mx-auto px-4 h-screen flex flex-col justify-start items-center overflow-y-auto snap-y snap-mandatory no-scrollbar"
|
|
>
|
|
{title && <h1 className="text-2xl font-bold py-8">{title}</h1>}
|
|
<div className="max-w-xl flex flex-col justify-start items-center">
|
|
{contents.map((content) => (
|
|
<div
|
|
key={content.id}
|
|
data-content-id={content.id}
|
|
className="w-full snap-start snap-always py-4"
|
|
>
|
|
<ContentCard content={content} onUpdate={fetchInitial} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div ref={loaderRef} className="py-8 flex justify-center">
|
|
{loading && <Spinner className="h-8 w-8 text-primary" />}
|
|
{!hasMore && contents.length > 0 && (
|
|
<p className="text-muted-foreground text-sm italic">
|
|
Vous avez atteint la fin ! 🐐
|
|
</p>
|
|
)}
|
|
{!loading && contents.length === 0 && (
|
|
<p className="text-muted-foreground text-sm italic">
|
|
Aucun mème trouvé ici... pour l'instant !
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|