Compare commits

...

3 Commits

Author SHA1 Message Date
d85d8697bc misc 2025-07-24 10:34:26 -04:00
8faf5de77f add memes 2025-07-24 10:06:36 -04:00
0ee80f4b49 cleanup/formatting + remove WIP 'alert', adjust radio station-tabs padding 2025-07-23 07:45:28 -04:00
9 changed files with 185 additions and 12 deletions

View File

@@ -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;
}

View File

@@ -241,4 +241,5 @@ Custom
max-height: 200px !important; max-height: 200px !important;
overflow-y: auto !important; overflow-y: auto !important;
overscroll-behavior: contain; overscroll-behavior: contain;
} }

View File

@@ -100,7 +100,7 @@ body {
} }
.station-tabs { .station-tabs {
padding-top: 2%; padding-top: 4%;
} }
/* Music bar and progress */ /* Music bar and progress */

View File

@@ -2,10 +2,9 @@
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { API_URL } from '../config'; import { API_URL } from '../config';
import { Player } from './AudioPlayer.jsx'; import { Player } from './AudioPlayer.jsx';
import Memes from './Memes.jsx';
import { JoyUIRootIsland } from './Components.jsx'; import { JoyUIRootIsland } from './Components.jsx';
import { PrimeReactProvider } from "primereact/api"; import { PrimeReactProvider } from "primereact/api";
import Alert from '@mui/joy/Alert';
import WarningIcon from '@mui/icons-material/Warning';
import CustomToastContainer from '../components/ToastProvider.jsx'; import CustomToastContainer from '../components/ToastProvider.jsx';
import LyricSearch from './LyricSearch.jsx'; import LyricSearch from './LyricSearch.jsx';
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css'; import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
@@ -23,15 +22,16 @@ export default function Root({child}) {
newestOnTop={true} newestOnTop={true}
closeOnClick={true}/> closeOnClick={true}/>
<JoyUIRootIsland> <JoyUIRootIsland>
<Alert {/* <Alert
className="alert" className="alert"
startDecorator={<WarningIcon />} startDecorator={<WarningIcon />}
variant="soft" variant="soft"
color="danger"> color="danger">
Work in progress... bugs are to be expected. Work in progress... bugs are to be expected.
</Alert> </Alert> */}
{child == "LyricSearch" && (<LyricSearch client:only="react" />)} {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> </JoyUIRootIsland>
</PrimeReactProvider> </PrimeReactProvider>
); );

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Howl, Howler } from "howler"; import { Howl, Howler } from "howler";
import { metaData } from "../config";
import Play from "@mui/icons-material/PlayArrow"; import Play from "@mui/icons-material/PlayArrow";
import Pause from "@mui/icons-material/Pause"; import Pause from "@mui/icons-material/Pause";
import "@styles/player.css"; import "@styles/player.css";
@@ -119,6 +120,10 @@ useEffect(() => {
clearGlobalMetadataInterval(); clearGlobalMetadataInterval();
currentStationForInterval = activeStation; currentStationForInterval = activeStation;
const setPageTitle = (artist, song) => {
document.title = metaData.title + ` - Radio - ${artist} - ${song} [${activeStation}]`;
}
const fetchTrackData = async () => { const fetchTrackData = async () => {
try { try {
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
@@ -156,6 +161,7 @@ useEffect(() => {
setTrackArtist(data.artist); setTrackArtist(data.artist);
setTrackGenre(data.genre !== "N/A" ? data.genre : ""); setTrackGenre(data.genre !== "N/A" ? data.genre : "");
setTrackAlbum(data.album); setTrackAlbum(data.album);
setPageTitle(data.artist, data.song);
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`); setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
setElapsed(data.elapsed); setElapsed(data.elapsed);
setDuration(data.duration); setDuration(data.duration);
@@ -182,6 +188,7 @@ const progressColorClass =
: "bg-blue-500 dark:bg-blue-400"; : "bg-blue-500 dark:bg-blue-400";
return ( return (
<> <>
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative"> <div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
@@ -224,10 +231,6 @@ const progressColorClass =
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
></div> ></div>
</div> </div>
<div className="music-control"> <div className="music-control">
<div <div
className="music-control__play" className="music-control__play"

132
src/components/Memes.jsx Normal file
View 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;

View File

@@ -9,7 +9,7 @@ import Themes from "astro-themes";
import { ViewTransitions } from "astro:transitions"; import { ViewTransitions } from "astro:transitions";
import BaseHead from "../components/BaseHead.astro"; import BaseHead from "../components/BaseHead.astro";
import Navbar from "../components/Nav.astro"; import Navbar from "./Nav.astro";
import Footer from "../components/Footer.astro"; import Footer from "../components/Footer.astro";
import "@fontsource/geist-sans/400.css"; import "@fontsource/geist-sans/400.css";

13
src/pages/memes.astro Normal file
View File

@@ -0,0 +1,13 @@
---
import Base from "../layouts/Base.astro";
import Root from "../components/AppLayout.jsx";
import "@styles/MemeGrid.css";
---
<Base>
<section>
<div class="prose prose-neutral dark:prose-invert">
<Root child="Memes" client:only="react">
</Root>
</section>
</Base>