2025-12-19 11:59:00 -05:00
|
|
|
|
import { useEffect, useState, useRef, useCallback, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
2025-07-24 10:06:36 -04:00
|
|
|
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
|
|
|
|
|
import { Dialog } from 'primereact/dialog';
|
|
|
|
|
|
import { Image } from 'primereact/image';
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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`;
|
2026-02-21 08:00:04 -05:00
|
|
|
|
const BASE_IMAGE_URL = "https://codey.horse/meme";
|
2025-07-24 10:06:36 -04:00
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
|
interface MemeImage {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
url: string;
|
|
|
|
|
|
filename?: string;
|
|
|
|
|
|
timestamp?: string;
|
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MemeCache {
|
|
|
|
|
|
pagesLoaded: Set<number>;
|
|
|
|
|
|
items: MemeImage[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const Memes: React.FC = () => {
|
|
|
|
|
|
const [images, setImages] = useState<MemeImage[]>([]);
|
|
|
|
|
|
const [page, setPage] = useState<number>(1);
|
|
|
|
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
|
|
|
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
|
|
|
|
|
const [selectedImage, setSelectedImage] = useState<MemeImage | null>(null);
|
|
|
|
|
|
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
|
|
|
|
|
const [imageLoading, setImageLoading] = useState<Record<string, boolean>>({});
|
|
|
|
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
|
|
|
|
const touchStartRef = useRef<number | null>(null);
|
|
|
|
|
|
const touchEndRef = useRef<number | null>(null);
|
2025-07-24 16:09:14 -04:00
|
|
|
|
const theme = document.documentElement.getAttribute("data-theme")
|
2025-12-19 11:59:00 -05:00
|
|
|
|
const cacheRef = useRef<MemeCache>({ pagesLoaded: new Set(), items: [] });
|
2025-11-25 10:04:05 -05:00
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
|
const prefetchImage = useCallback((img: MemeImage | undefined): void => {
|
2025-11-25 10:04:05 -05:00
|
|
|
|
if (!img || typeof window === "undefined") return;
|
|
|
|
|
|
const preload = new window.Image();
|
|
|
|
|
|
preload.src = img.url;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
|
const handleNavigate = useCallback((direction: number): void => {
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-12-19 11:59:00 -05:00
|
|
|
|
const handleKeyDown = (event: globalThis.KeyboardEvent): void => {
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
2025-12-19 11:59:00 -05:00
|
|
|
|
const persistCache = useCallback((nextPage: number): void => {
|
2025-11-25 10:04:05 -05:00
|
|
|
|
sessionStorage.setItem("memes-cache", JSON.stringify({
|
|
|
|
|
|
items: cacheRef.current.items,
|
|
|
|
|
|
pagesLoaded: Array.from(cacheRef.current.pagesLoaded),
|
|
|
|
|
|
nextPage,
|
|
|
|
|
|
hasMore,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}, [hasMore]);
|
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
|
const loadImages = async (pageNum: number, attempt: number = 0): Promise<void> => {
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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
|
|
|
|
}));
|
|
|
|
|
|
|
2025-11-25 10:04:05 -05: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(() => {
|
2025-11-25 10:04:05 -05:00
|
|
|
|
if (!cacheRef.current.pagesLoaded.size) {
|
|
|
|
|
|
loadImages(1);
|
|
|
|
|
|
}
|
2025-07-24 10:06:36 -04:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
|
const canGoPrev = selectedIndex > 0;
|
|
|
|
|
|
const canGoNext = selectedIndex >= 0 && selectedIndex < images.length - 1;
|
|
|
|
|
|
|
|
|
|
|
|
const closeDialog = useCallback(() => {
|
|
|
|
|
|
setSelectedImage(null);
|
|
|
|
|
|
setSelectedIndex(-1);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
|
// 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 }));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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;
|
2025-12-05 14:21:52 -05:00
|
|
|
|
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}
|
2025-11-25 10:04:05 -05:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedImage(img);
|
|
|
|
|
|
setSelectedIndex(i);
|
|
|
|
|
|
prefetchImage(images[i + 1]);
|
|
|
|
|
|
}}
|
2025-12-05 14:21:52 -05:00
|
|
|
|
style={{ cursor: 'pointer', position: 'relative' }}
|
2025-07-24 10:06:36 -04:00
|
|
|
|
>
|
2025-12-05 14:21:52 -05:00
|
|
|
|
{isLoading && (
|
|
|
|
|
|
<div className="meme-skeleton" />
|
|
|
|
|
|
)}
|
2025-07-24 10:06:36 -04:00
|
|
|
|
<Image
|
|
|
|
|
|
src={img.url}
|
|
|
|
|
|
alt={`meme-${img.id}`}
|
2025-12-05 14:21:52 -05:00
|
|
|
|
imageClassName={`meme-img ${isLoading ? 'meme-img-loading' : ''}`}
|
2025-07-24 10:06:36 -04:00
|
|
|
|
loading="lazy"
|
2025-12-05 14:21:52 -05:00
|
|
|
|
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
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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}
|
2025-11-25 10:04:05 -05:00
|
|
|
|
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 && (
|
2025-12-05 14:21:52 -05:00
|
|
|
|
<div
|
|
|
|
|
|
className="meme-dialog-body"
|
|
|
|
|
|
onTouchStart={handleTouchStart}
|
|
|
|
|
|
onTouchMove={handleTouchMove}
|
|
|
|
|
|
onTouchEnd={handleTouchEnd}
|
|
|
|
|
|
>
|
2025-11-25 10:04:05 -05:00
|
|
|
|
<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;
|