add memes
This commit is contained in:
		@@ -2,6 +2,7 @@
 | 
			
		||||
import { toast } from 'react-toastify';
 | 
			
		||||
import { API_URL } from '../config';
 | 
			
		||||
import { Player } from './AudioPlayer.jsx';
 | 
			
		||||
import Memes from './Memes.jsx';
 | 
			
		||||
import { JoyUIRootIsland } from './Components.jsx';
 | 
			
		||||
import { PrimeReactProvider } from "primereact/api";
 | 
			
		||||
import CustomToastContainer from '../components/ToastProvider.jsx';
 | 
			
		||||
@@ -29,7 +30,8 @@ export default function Root({child}) {
 | 
			
		||||
          Work in progress... bugs are to be expected.
 | 
			
		||||
        </Alert> */}
 | 
			
		||||
    {child == "LyricSearch" && (<LyricSearch client:only="react" />)}
 | 
			
		||||
    {child == "Player" && (<Player client:only="react"></Player>)}
 | 
			
		||||
    {child == "Player" && (<Player client:only="react" />)}
 | 
			
		||||
    {child == "Memes" && <Memes client:only="react" />}
 | 
			
		||||
    </JoyUIRootIsland>
 | 
			
		||||
    </PrimeReactProvider>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { useState, useEffect, useRef } from "react";
 | 
			
		||||
import { Howl, Howler } from "howler";
 | 
			
		||||
import { metaData } from "../config";
 | 
			
		||||
import Play from "@mui/icons-material/PlayArrow";
 | 
			
		||||
import Pause from "@mui/icons-material/Pause";
 | 
			
		||||
import "@styles/player.css";
 | 
			
		||||
@@ -119,6 +120,10 @@ useEffect(() => {
 | 
			
		||||
    clearGlobalMetadataInterval();
 | 
			
		||||
    currentStationForInterval = activeStation;
 | 
			
		||||
 | 
			
		||||
    const setPageTitle = (artist, song) => {
 | 
			
		||||
      document.title = metaData.title + ` - Radio - ${artist} - ${song} [${activeStation}]`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const fetchTrackData = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
 | 
			
		||||
@@ -156,6 +161,7 @@ useEffect(() => {
 | 
			
		||||
        setTrackArtist(data.artist);
 | 
			
		||||
        setTrackGenre(data.genre !== "N/A" ? data.genre : "");
 | 
			
		||||
        setTrackAlbum(data.album);
 | 
			
		||||
        setPageTitle(data.artist, data.song);
 | 
			
		||||
        setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
 | 
			
		||||
        setElapsed(data.elapsed);
 | 
			
		||||
        setDuration(data.duration);
 | 
			
		||||
@@ -182,6 +188,7 @@ const progressColorClass =
 | 
			
		||||
    : "bg-blue-500 dark:bg-blue-400";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										132
									
								
								src/components/Memes.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/components/Memes.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,132 @@
 | 
			
		||||
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,
 | 
			
		||||
        url: `${BASE_IMAGE_URL}/${m.id}.png`
 | 
			
		||||
      }));
 | 
			
		||||
 | 
			
		||||
      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
 | 
			
		||||
        header={`Meme #${selectedImage?.id}`}
 | 
			
		||||
        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;
 | 
			
		||||
@@ -6,6 +6,7 @@ import ExitToApp from '@mui/icons-material/ExitToApp';
 | 
			
		||||
const navItems = [
 | 
			
		||||
  { label: "Home", href: "/" },
 | 
			
		||||
  { label: "Radio", href: "/radio" },
 | 
			
		||||
  { label: "Memes", href: "/memes" },
 | 
			
		||||
  { blockSeparator: true },
 | 
			
		||||
  { label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
 | 
			
		||||
  { label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user