Files
codey.lol/src/components/Memes.jsx

333 lines
10 KiB
React
Raw Normal View History

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 IconButton from '@mui/joy/IconButton';
import FileCopyRoundedIcon from '@mui/icons-material/FileCopyRounded';
import { toast } from 'react-toastify';
2025-07-24 10:06:36 -04:00
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 [selectedIndex, setSelectedIndex] = useState(-1);
const [imageLoading, setImageLoading] = useState({});
2025-07-24 10:06:36 -04:00
const observerRef = useRef();
const touchStartRef = useRef(null);
const touchEndRef = useRef(null);
2025-07-24 16:09:14 -04:00
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
const prefetchImage = useCallback((img) => {
if (!img || typeof window === "undefined") return;
const preload = new window.Image();
preload.src = img.url;
}, []);
const handleNavigate = useCallback((direction) => {
setSelectedIndex((prev) => {
const newIndex = prev + direction;
if (newIndex < 0 || newIndex >= images.length) {
return prev;
}
const nextImage = images[newIndex];
setSelectedImage(nextImage);
prefetchImage(images[newIndex + 1]);
return newIndex;
});
}, [images, prefetchImage]);
useEffect(() => {
if (!selectedImage) {
return;
}
const handleKeyDown = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
handleNavigate(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
handleNavigate(1);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedImage, handleNavigate]);
useEffect(() => {
const cached = sessionStorage.getItem("memes-cache");
if (cached) {
try {
const parsed = JSON.parse(cached);
cacheRef.current = {
pagesLoaded: new Set(parsed.pagesLoaded || []),
items: parsed.items || [],
};
setImages(parsed.items || []);
setPage(parsed.nextPage || 1);
setHasMore(parsed.hasMore ?? true);
} catch (e) {
console.warn("Failed to parse meme cache", e);
}
}
}, []);
const persistCache = useCallback((nextPage) => {
sessionStorage.setItem("memes-cache", JSON.stringify({
items: cacheRef.current.items,
pagesLoaded: Array.from(cacheRef.current.pagesLoaded),
nextPage,
hasMore,
}));
}, [hasMore]);
2025-07-24 10:06:36 -04:00
const loadImages = async (pageNum, attempt = 0) => {
if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) return;
2025-07-24 10:06:36 -04:00
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)
2025-08-09 07:10:04 -04:00
.toString().split(" ")
.splice(0, 4).join(" "),
2025-07-24 15:16:26 -04:00
url: `${BASE_IMAGE_URL}/${m.id}.png`,
2025-07-24 10:06:36 -04:00
}));
cacheRef.current.pagesLoaded.add(pageNum);
cacheRef.current.items = [...cacheRef.current.items, ...imageObjects];
setImages(cacheRef.current.items);
setPage(prev => {
const next = prev + 1;
persistCache(next);
return next;
});
2025-07-24 10:06:36 -04:00
} 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(() => {
if (!cacheRef.current.pagesLoaded.size) {
loadImages(1);
}
2025-07-24 10:06:36 -04:00
}, []);
const canGoPrev = selectedIndex > 0;
const canGoNext = selectedIndex >= 0 && selectedIndex < images.length - 1;
const closeDialog = useCallback(() => {
setSelectedImage(null);
setSelectedIndex(-1);
}, []);
// Touch swipe handlers for mobile navigation
const handleTouchStart = useCallback((e) => {
touchStartRef.current = e.touches[0].clientX;
touchEndRef.current = null;
}, []);
const handleTouchMove = useCallback((e) => {
touchEndRef.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback(() => {
if (!touchStartRef.current || !touchEndRef.current) return;
const distance = touchStartRef.current - touchEndRef.current;
const minSwipeDistance = 50;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// Swiped left -> next
handleNavigate(1);
} else {
// Swiped right -> prev
handleNavigate(-1);
}
}
touchStartRef.current = null;
touchEndRef.current = null;
}, [handleNavigate]);
// Track image loading state
const handleImageLoad = useCallback((id) => {
setImageLoading(prev => ({ ...prev, [id]: false }));
}, []);
const handleImageLoadStart = useCallback((id) => {
setImageLoading(prev => ({ ...prev, [id]: true }));
}, []);
const handleCopyImage = useCallback(async () => {
if (!selectedImage) return;
try {
const res = await fetch(selectedImage.url, { mode: "cors" });
const blob = await res.blob();
if (!navigator.clipboard?.write) {
throw new Error("Clipboard API not available");
}
await navigator.clipboard.write([
new window.ClipboardItem({ [blob.type || "image/png"]: blob })
]);
toast.success("Image copied", { autoClose: 1500 });
} catch (err) {
console.error("Copy image failed", err);
toast.error("Unable to copy image");
}
}, [selectedImage]);
2025-07-24 10:06:36 -04:00
return (
<>
<div className="grid-container">
{images.map((img, i) => {
const isLast = i === images.length - 1;
const isLoading = imageLoading[img.id] !== false;
2025-07-24 10:06:36 -04:00
return (
<div
key={img.id}
className="grid-item"
ref={isLast ? lastImageRef : null}
onClick={() => {
setSelectedImage(img);
setSelectedIndex(i);
prefetchImage(images[i + 1]);
}}
style={{ cursor: 'pointer', position: 'relative' }}
2025-07-24 10:06:36 -04:00
>
{isLoading && (
<div className="meme-skeleton" />
)}
2025-07-24 10:06:36 -04:00
<Image
src={img.url}
alt={`meme-${img.id}`}
imageClassName={`meme-img ${isLoading ? 'meme-img-loading' : ''}`}
2025-07-24 10:06:36 -04:00
loading="lazy"
onLoad={() => handleImageLoad(img.id)}
onLoadStart={() => handleImageLoadStart(img.id)}
2025-07-24 10:06:36 -04:00
/>
</div>
);
})}
{loading && (
<div className="loading">
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
</div>
)}
</div>
{/* Dialog for enlarged image */}
<Dialog
header={
<div className="meme-dialog-header">
<span>Meme #{selectedImage?.id} - {selectedImage?.timestamp}</span>
<div className="meme-dialog-actions">
<div className="meme-dialog-tooltip-wrapper">
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Copy image"
onClick={handleCopyImage}
>
<FileCopyRoundedIcon fontSize="small" />
</IconButton>
<span className="meme-dialog-tooltip">Copy image</span>
</div>
</div>
</div>
}
2025-07-24 10:06:36 -04:00
visible={!!selectedImage}
onHide={closeDialog}
2025-07-24 10:06:36 -04:00
style={{ width: '90vw', maxWidth: '720px' }}
2025-07-24 16:09:14 -04:00
className={`d-${theme}`}
2025-07-24 10:06:36 -04:00
modal
closable
dismissableMask={true}
>
{selectedImage && (
<div
className="meme-dialog-body"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button
type="button"
className="meme-dialog-nav meme-dialog-nav-prev"
onClick={() => handleNavigate(-1)}
disabled={!canGoPrev}
aria-label="Previous meme"
>
</button>
<img
src={selectedImage.url}
alt={`meme-${selectedImage.id}`}
style={{
maxWidth: '100%',
maxHeight: '70vh',
objectFit: 'contain',
display: 'block',
margin: '0 auto',
borderRadius: '6px',
}}
/>
<button
type="button"
className="meme-dialog-nav meme-dialog-nav-next"
onClick={() => handleNavigate(1)}
disabled={!canGoNext}
aria-label="Next meme"
>
</button>
</div>
2025-07-24 10:06:36 -04:00
)}
</Dialog>
</>
);
};
export default Memes;