Files
codey.lol/src/components/Memes.tsx
2025-12-19 11:59:00 -05:00

346 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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);
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef<MemeCache>({ 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<void> => {
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 (
<>
<div className="grid-container">
{images.map((img, i) => {
const isLast = i === images.length - 1;
const isLoading = imageLoading[img.id] !== false;
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' }}
>
{isLoading && (
<div className="meme-skeleton" />
)}
<Image
src={img.url}
alt={`meme-${img.id}`}
imageClassName={`meme-img ${isLoading ? 'meme-img-loading' : ''}`}
loading="lazy"
onLoad={() => handleImageLoad(img.id)}
onLoadStart={() => handleImageLoadStart(img.id)}
/>
</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>
}
visible={!!selectedImage}
onHide={closeDialog}
style={{ width: '90vw', maxWidth: '720px' }}
className={`d-${theme}`}
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>
)}
</Dialog>
</>
);
};
export default Memes;