diff --git a/src/assets/styles/MemeGrid.css b/src/assets/styles/MemeGrid.css
new file mode 100644
index 0000000..2ee82a5
--- /dev/null
+++ b/src/assets/styles/MemeGrid.css
@@ -0,0 +1,24 @@
+.grid-container {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.grid-item {
+ width: 100%;
+ aspect-ratio: 1/1;
+ overflow: hidden;
+}
+
+.meme-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.loading {
+ text-align: center;
+ margin: 2rem 0;
+}
diff --git a/src/components/AppLayout.jsx b/src/components/AppLayout.jsx
index 57965be..8c3c074 100644
--- a/src/components/AppLayout.jsx
+++ b/src/components/AppLayout.jsx
@@ -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.
*/}
{child == "LyricSearch" && ()}
- {child == "Player" && ()}
+ {child == "Player" && ()}
+ {child == "Memes" && }
);
diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx
index ed7f286..3822663 100644
--- a/src/components/AudioPlayer.jsx
+++ b/src/components/AudioPlayer.jsx
@@ -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 (
<>
diff --git a/src/components/Memes.jsx b/src/components/Memes.jsx
new file mode 100644
index 0000000..e398e1b
--- /dev/null
+++ b/src/components/Memes.jsx
@@ -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 (
+ <>
+
+ {images.map((img, i) => {
+ const isLast = i === images.length - 1;
+ return (
+
setSelectedImage(img)}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+ );
+ })}
+ {loading && (
+
+ )}
+
+
+ {/* Dialog for enlarged image */}
+
+ >
+ );
+};
+
+export default Memes;
diff --git a/src/components/Nav.astro b/src/components/Nav.astro
index b628364..d1dddb1 100644
--- a/src/components/Nav.astro
+++ b/src/components/Nav.astro
@@ -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 },
diff --git a/src/pages/memes.astro b/src/pages/memes.astro
new file mode 100644
index 0000000..4472810
--- /dev/null
+++ b/src/pages/memes.astro
@@ -0,0 +1,13 @@
+---
+import Base from "../layouts/Base.astro";
+import Root from "../components/AppLayout.jsx";
+import "@styles/MemeGrid.css";
+---
+
+
+