2025-07-24 10:06:36 -04:00
|
|
|
import { useEffect, useState, useRef, useCallback } from "react";
|
|
|
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
|
|
|
import { Dialog } from 'primereact/dialog';
|
|
|
|
import { Image } from 'primereact/image';
|
|
|
|
import { toast } from 'react-toastify';
|
|
|
|
import { API_URL } from '../config';
|
|
|
|
|
|
|
|
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
|
|
|
const BASE_IMAGE_URL = "https://codey.lol/meme";
|
|
|
|
|
|
|
|
const Memes = () => {
|
|
|
|
const [images, setImages] = useState([]);
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const [hasMore, setHasMore] = useState(true);
|
|
|
|
const [selectedImage, setSelectedImage] = useState(null);
|
|
|
|
const observerRef = useRef();
|
|
|
|
|
|
|
|
const loadImages = async (pageNum, attempt = 0) => {
|
|
|
|
if (loading || !hasMore) return;
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
|
|
const res = await fetch(`${MEME_API_URL}?page=${pageNum}`);
|
|
|
|
|
|
|
|
if (res.status === 429) {
|
|
|
|
const backoff = Math.min(1000 * Math.pow(2, attempt), 10000); // max 10s
|
|
|
|
console.warn(`Rate limited. Retrying in ${backoff}ms`);
|
|
|
|
setTimeout(() => {
|
|
|
|
loadImages(pageNum, attempt + 1);
|
|
|
|
}, backoff);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
const newMemes = data?.memes || [];
|
|
|
|
|
|
|
|
if (newMemes.length === 0 || data.paging.current >= data.paging.of) {
|
|
|
|
setHasMore(false);
|
|
|
|
}
|
|
|
|
|
|
|
|
const imageObjects = newMemes.map(m => ({
|
|
|
|
id: m.id,
|
2025-07-24 15:16:26 -04:00
|
|
|
timestamp: new Date(m.timestamp * 1000)
|
|
|
|
.toString().split(" ")
|
|
|
|
.splice(0, 4).join(" "),
|
|
|
|
url: `${BASE_IMAGE_URL}/${m.id}.png`,
|
2025-07-24 10:06:36 -04:00
|
|
|
}));
|
|
|
|
|
|
|
|
setImages(prev => [...prev, ...imageObjects]);
|
|
|
|
setPage(prev => prev + 1);
|
|
|
|
} catch (e) {
|
|
|
|
console.error("Failed to load memes", e);
|
|
|
|
toast.error("Failed to load more memes.");
|
|
|
|
} finally {
|
|
|
|
setLoading(false);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const lastImageRef = useCallback(node => {
|
|
|
|
if (loading) return;
|
|
|
|
if (observerRef.current) observerRef.current.disconnect();
|
|
|
|
observerRef.current = new IntersectionObserver(entries => {
|
|
|
|
if (entries[0].isIntersecting && hasMore) {
|
|
|
|
loadImages(page);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
if (node) observerRef.current.observe(node);
|
|
|
|
}, [loading, hasMore, page]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
loadImages(1);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<div className="grid-container">
|
|
|
|
{images.map((img, i) => {
|
|
|
|
const isLast = i === images.length - 1;
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
key={img.id}
|
|
|
|
className="grid-item"
|
|
|
|
ref={isLast ? lastImageRef : null}
|
|
|
|
onClick={() => setSelectedImage(img)}
|
|
|
|
style={{ cursor: 'pointer' }}
|
|
|
|
>
|
|
|
|
<Image
|
|
|
|
src={img.url}
|
|
|
|
alt={`meme-${img.id}`}
|
|
|
|
imageClassName="meme-img"
|
|
|
|
loading="lazy"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
{loading && (
|
|
|
|
<div className="loading">
|
|
|
|
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/* Dialog for enlarged image */}
|
|
|
|
<Dialog
|
2025-07-24 15:16:26 -04:00
|
|
|
header={`Meme #${selectedImage?.id} - ${selectedImage?.timestamp}`}
|
2025-07-24 10:06:36 -04:00
|
|
|
visible={!!selectedImage}
|
|
|
|
onHide={() => setSelectedImage(null)}
|
|
|
|
style={{ width: '90vw', maxWidth: '720px' }}
|
|
|
|
modal
|
|
|
|
closable
|
|
|
|
dismissableMask={true}
|
|
|
|
>
|
|
|
|
{selectedImage && (
|
|
|
|
<img
|
|
|
|
src={selectedImage.url}
|
|
|
|
alt={`meme-${selectedImage.id}`}
|
|
|
|
style={{
|
|
|
|
maxWidth: '100%',
|
|
|
|
maxHeight: '70vh', // restrict height to viewport height
|
|
|
|
objectFit: 'contain',
|
|
|
|
display: 'block',
|
|
|
|
margin: '0 auto',
|
|
|
|
borderRadius: '6px',
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
|
|
|
|
)}
|
|
|
|
</Dialog>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default Memes;
|