import { useEffect, useState, useRef, useCallback, type KeyboardEvent as ReactKeyboardEvent } 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'; import { API_URL } from '../config'; const MEME_API_URL = `${API_URL}/memes/list_memes`; const BASE_IMAGE_URL = "https://codey.lol/meme"; interface MemeImage { id: string; url: string; filename?: string; timestamp?: string; [key: string]: unknown; } interface MemeCache { pagesLoaded: Set; items: MemeImage[]; } const Memes: React.FC = () => { 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>({}); const observerRef = useRef(null); const touchStartRef = useRef(null); const touchEndRef = useRef(null); const theme = document.documentElement.getAttribute("data-theme") const cacheRef = useRef({ pagesLoaded: new Set(), items: [] }); const prefetchImage = useCallback((img: MemeImage | undefined): void => { if (!img || typeof window === "undefined") return; const preload = new window.Image(); preload.src = img.url; }, []); const handleNavigate = useCallback((direction: number): void => { 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: globalThis.KeyboardEvent): void => { 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: number): void => { sessionStorage.setItem("memes-cache", JSON.stringify({ items: cacheRef.current.items, pagesLoaded: Array.from(cacheRef.current.pagesLoaded), nextPage, hasMore, })); }, [hasMore]); const loadImages = async (pageNum: number, attempt: number = 0): Promise => { if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) 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, timestamp: new Date(m.timestamp * 1000) .toString().split(" ") .splice(0, 4).join(" "), url: `${BASE_IMAGE_URL}/${m.id}.png`, })); 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; }); } 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); } }, []); 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]); return ( <>
{images.map((img, i) => { const isLast = i === images.length - 1; const isLoading = imageLoading[img.id] !== false; return (
{ setSelectedImage(img); setSelectedIndex(i); prefetchImage(images[i + 1]); }} style={{ cursor: 'pointer', position: 'relative' }} > {isLoading && (
)} {`meme-${img.id}`} handleImageLoad(img.id)} onLoadStart={() => handleImageLoadStart(img.id)} />
); })} {loading && (
)}
{/* Dialog for enlarged image */} Meme #{selectedImage?.id} - {selectedImage?.timestamp}
Copy image
} visible={!!selectedImage} onHide={closeDialog} style={{ width: '90vw', maxWidth: '720px' }} className={`d-${theme}`} modal closable dismissableMask={true} > {selectedImage && (
{`meme-${selectedImage.id}`}
)} ); }; export default Memes;